diff --git a/crates/pm-web/src/main.rs b/crates/pm-web/src/main.rs index 7377bcd..97213ab 100644 --- a/crates/pm-web/src/main.rs +++ b/crates/pm-web/src/main.rs @@ -1,19 +1,21 @@ //! pm-web — Linux Patch Manager web server. -mod secret_key; - mod routes; +mod secret_key; + use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router}; use axum_server::tls_rustls::RustlsConfig; use dashmap::DashMap; use pm_auth::{ jwt, + password::hash_password, rbac::{require_auth, AuthConfig}, }; use pm_core::{ config::AppConfig, db, logging, models::ApprovedEntry, request_id::request_id_middleware, }; +use rand::Rng; use routes::sso::{OidcCache, SsoHandoff, SsoSession}; use routes::ws::WsTicket; use serde_json::{json, Value}; @@ -27,6 +29,93 @@ use tower_http::{ trace::TraceLayer, }; +/// Placeholder Argon2id hash prefix used in the seed admin migration (issue #8). +/// Detecting this prefix means the admin password has not been bootstrapped yet. +const ADMIN_PLACEHOLDER_HASH_PREFIX: &str = "$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA"; + +/// Bootstrap the default admin account with a random password. +/// +/// On first startup after a fresh install, the `users` table contains the seed +/// admin row with a clearly-invalid placeholder hash (cannot validate any password). +/// This function detects that placeholder, generates a cryptographically random +/// 24-character password, hashes it with Argon2id, and UPDATEs the admin row. +/// +/// The plaintext password is printed **once** to stderr (visible in `systemctl status` +/// or `journalctl`) and is never stored on disk. +/// +/// If the admin row already has a real hash, this function is a no-op. +async fn bootstrap_admin_password(pool: &sqlx::PgPool) { + // Check if the admin account still has the placeholder hash. + let result: Option = sqlx::query_scalar( + "SELECT password_hash FROM users WHERE username = 'admin' AND auth_provider = 'local'", + ) + .fetch_optional(pool) + .await + .unwrap_or(None); + + let current_hash = match result { + Some(h) => h, + None => return, // No admin row — nothing to bootstrap. + }; + + if !current_hash.starts_with(ADMIN_PLACEHOLDER_HASH_PREFIX) { + // Admin already has a real password — nothing to do. + return; + } + + // Generate a 24-character random alphanumeric password. + let password: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(24) + .map(char::from) + .collect(); + + // Hash it with the application's Argon2id parameters. + let new_hash = match hash_password(&password) { + Ok(h) => h, + Err(e) => { + tracing::error!(error = %e, "Failed to hash bootstrap admin password"); + return; + }, + }; + + // Replace the placeholder hash with the real one. + // The WHERE clause matches the placeholder prefix to ensure idempotency. + let rows = sqlx::query( + r#"UPDATE users + SET password_hash = $1 + WHERE username = 'admin' + AND auth_provider = 'local' + AND password_hash LIKE '$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'"#, + ) + .bind(&new_hash) + .execute(pool) + .await; + + match rows { + Ok(result) if result.rows_affected() == 1 => { + eprintln!(); + eprintln!("========================================"); + eprintln!(" INITIAL ADMIN PASSWORD (shown once)"); + eprintln!(" Username: admin"); + eprintln!(" Password: {}", password); + eprintln!(); + eprintln!(" You will be forced to change this on first login."); + eprintln!(" If lost, restart the service to generate a new one."); + eprintln!("========================================"); + eprintln!(); + tracing::info!("Bootstrap admin password generated and set"); + }, + Ok(_) => { + // Rows affected != 1 — concurrent bootstrap or already replaced. + tracing::info!("Admin password already bootstrapped (concurrent or prior)"); + }, + Err(e) => { + tracing::error!(error = %e, "Failed to update admin password hash"); + }, + } +} + /// Shared application state threaded through Axum. #[derive(Clone)] pub struct AppState { @@ -94,6 +183,9 @@ async fn main() -> anyhow::Result<()> { let pool = db::init_pool(&config.database).await?; db::run_migrations(&pool).await?; + // Bootstrap admin password if the seed admin still has the placeholder hash (issue #8). + bootstrap_admin_password(&pool).await; + // Initialise the internal CA using the configured certificate paths. // The CA certificate and key must exist at the configured locations and be // unencrypted PEM. If absent, a new CA is generated in that directory. diff --git a/migrations/002_seed_admin.sql b/migrations/002_seed_admin.sql index 7799477..8c3e7af 100644 --- a/migrations/002_seed_admin.sql +++ b/migrations/002_seed_admin.sql @@ -1,12 +1,17 @@ -- Migration: 002_seed_admin -- Description: Seed the default admin account. -- --- Default credentials (CHANGE BEFORE PRODUCTION USE): --- Username: admin --- Password: ChangeMe123! +-- IMPORTANT (issue #8): The password_hash below is a PLACEHOLDER +-- that cannot validate any password. On first startup, pm-web detects +-- this placeholder and generates a random admin password, replacing +-- the hash in the database. The generated password is printed once +-- to stderr (visible in systemd journal). -- --- The password hash below is Argon2id of "ChangeMe123!" with --- m=65536, t=3, p=1. Replace after first login. +-- If the application never starts (e.g., manual migration only), +-- the admin account is inaccessible — this is fail-closed. +-- +-- On first successful login with a real password, the admin is forced to +-- set a new password (force_password_reset = TRUE). INSERT INTO users ( id, @@ -27,10 +32,11 @@ VALUES ( 'admin@localhost', 'admin', 'local', - -- Argon2id hash of "ChangeMe123!" — REPLACE IN PRODUCTION - '$argon2id$v=19$m=65536,t=3,p=1$Kv8bkGiE81yIuXARq9fwsw$NrBRFvgL1dVsW7bEK6NxEOzIX2q1p4B0K422idAVIDQ', - FALSE, -- MFA disabled by default; admin must set up on first login + -- PLACEHOLDER Argon2id hash (issue #8). Cannot validate any password. + -- pm-web replaces this with a real hash on first startup. + '$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + FALSE, TRUE, - TRUE -- Force password reset on first login + TRUE ) ON CONFLICT (username) DO NOTHING;