diff --git a/Cargo.lock b/Cargo.lock index 6e5a776f..faa32d37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -475,6 +475,12 @@ dependencies = [ "syn", ] +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + [[package]] name = "base64" version = "0.22.1" @@ -826,6 +832,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "cookie" version = "0.18.1" @@ -1670,7 +1682,16 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac", + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", ] [[package]] @@ -2751,6 +2772,12 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-error" version = "2.0.1" @@ -3005,7 +3032,9 @@ dependencies = [ "libc", "lol_html", "pretty_assertions", + "qrcode", "regex", + "ring", "ruscker-config", "ruscker-core", "ruscker-docker", @@ -3018,6 +3047,7 @@ dependencies = [ "thiserror", "tokio", "tokio-tungstenite", + "totp-rs", "tower", "tower-cookies", "tower-http", @@ -3653,7 +3683,7 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac", + "hmac 0.13.0", "itoa", "log", "md-5", @@ -3948,6 +3978,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "totp-rs" +version = "5.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e69a15e21b2ff22c415446983978bded3244195f17d59cb113551c1e806f91" +dependencies = [ + "base32", + "constant_time_eq", + "hmac 0.12.1", + "rand 0.9.4", + "sha1 0.10.6", + "sha2 0.10.9", + "url", + "urlencoding", +] + [[package]] name = "tower" version = "0.5.3" @@ -4247,6 +4293,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 5c698000..41cb51c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,10 @@ hex = "0.4" # Password hashing for admin user accounts (argon2id). `std` pulls in # the OsRng salt generator via password-hash. argon2 = { version = "0.5", features = ["std"] } +# TOTP enrollment (#1005 slice 2). Keep QR rendering separate so +# `totp-rs` does not pull its image stack into the server binary. +totp-rs = { version = "5.7.2", features = ["gen_secret", "otpauth"] } +qrcode = { version = "0.14.1", default-features = false, features = ["svg"] } # Testing pretty_assertions = "1.4" diff --git a/crates/ruscker-admin/Cargo.toml b/crates/ruscker-admin/Cargo.toml index 702075ca..f38d6dbe 100644 --- a/crates/ruscker-admin/Cargo.toml +++ b/crates/ruscker-admin/Cargo.toml @@ -98,6 +98,11 @@ base64 = { workspace = true } # User accounts — password hashing (argon2id) argon2 = { workspace = true } +# User TOTP enrollment + dependency-light inline SVG QR rendering (#1005). +totp-rs = { workspace = true } +qrcode = { workspace = true } +ring = { workspace = true } + # Async trait for the SessionStore seam (in-memory now, Postgres in HA) async-trait = { workspace = true } diff --git a/crates/ruscker-admin/assets/i18n/en/landing.ftl b/crates/ruscker-admin/assets/i18n/en/landing.ftl index 42770d92..027e484c 100644 --- a/crates/ruscker-admin/assets/i18n/en/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/en/landing.ftl @@ -993,3 +993,39 @@ admin-schedules-flash-toggled = Schedule updated. admin-schedules-flash-bad-cron = Invalid cron expression — use the 5-field form, e.g. "0 3 * * *". admin-schedules-flash-bad-spec = Unknown app, or the app has no container image to run. admin-schedules-flash-error = The operation failed — check the server logs. + +# — TOTP / two-factor authentication (#1005) +chrome-mfa = Two-factor authentication +admin-mfa-title = Two-factor authentication +admin-mfa-help = Protect your account with temporary codes from an authenticator app. +admin-mfa-error-password = Your current password is incorrect. +admin-mfa-error-key = RUSCKER_MASTER_KEY is not configured. Configure it and restart Ruscker before enrolling 2FA. +admin-mfa-break-glass = Break-glass token sessions have no account or password and cannot enroll 2FA. Sign in with a username and password. +admin-mfa-already = 2FA is already configured. An administrator must reset it before you can enroll again. +admin-mfa-enrolled = 2FA configured +admin-mfa-enrolled-since = Configured since +admin-mfa-reenroll-note = To change authenticator apps, ask an administrator to reset your 2FA and then enroll again. +admin-mfa-not-enrolled = 2FA not configured +admin-mfa-pending-note = An enrollment is incomplete. Enter your password to restart with a new key. +admin-mfa-current-password = Current password +admin-mfa-start = Set up 2FA +admin-mfa-setup-title = Link your authenticator app +admin-mfa-setup-help = Scan the QR code in your app, then enter the 6-digit code it generates. +admin-mfa-error-rate = Too many incorrect attempts. Wait one minute and try again. +admin-mfa-error-code = Incorrect or expired code. Check your device clock and try again. +admin-mfa-manual-title = Manual setup key +admin-mfa-manual-help = If you cannot scan the QR code, enter this key in your authenticator app. +admin-mfa-profile = Profile: SHA-1, 6 digits, 30-second period. +admin-mfa-code-label = 6-digit code +admin-mfa-confirm = Confirm and enable +admin-mfa-recovery-title = Save your recovery codes +admin-mfa-recovery-warning = These codes are shown only once. Copy or save them somewhere safe now. +admin-mfa-recovery-help = Each code can be used only once if you lose access to your authenticator app. +admin-mfa-continue = Continue +admin-users-mfa-section = Two-factor authentication +admin-users-mfa-configured = 2FA configured since +admin-users-mfa-reset-hint = Resetting deletes the key and all recovery codes. The user will need to enroll 2FA again. +admin-users-mfa-reset-confirm = Reset this user's 2FA? The key and ALL recovery codes will be deleted immediately. +admin-users-mfa-reset = Reset 2FA +admin-users-mfa-not-configured = 2FA not configured +admin-users-mfa-reset-ok = 2FA and its recovery codes were reset. diff --git a/crates/ruscker-admin/assets/i18n/es/landing.ftl b/crates/ruscker-admin/assets/i18n/es/landing.ftl index 988904e2..a9ee16d2 100644 --- a/crates/ruscker-admin/assets/i18n/es/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/es/landing.ftl @@ -993,3 +993,39 @@ admin-schedules-flash-toggled = Programación actualizada. admin-schedules-flash-bad-cron = Expresión cron no válida — usa la forma de 5 campos, p. ej. "0 3 * * *". admin-schedules-flash-bad-spec = App desconocida, o la app no tiene imagen de contenedor que ejecutar. admin-schedules-flash-error = La operación falló — revisa los logs del servidor. + +# — TOTP / autenticación de dos factores (#1005) +chrome-mfa = Autenticación de dos factores +admin-mfa-title = Autenticación de dos factores +admin-mfa-help = Protege tu cuenta con códigos temporales de una aplicación de autenticación. +admin-mfa-error-password = La contraseña actual es incorrecta. +admin-mfa-error-key = RUSCKER_MASTER_KEY no está configurada. Configúrala y reinicia Ruscker antes de registrar el 2FA. +admin-mfa-break-glass = Las sesiones de emergencia por token no tienen cuenta ni contraseña y no pueden configurar 2FA. Inicia sesión con usuario y contraseña. +admin-mfa-already = El 2FA ya está configurado. Un administrador debe restablecerlo antes de volver a configurarlo. +admin-mfa-enrolled = 2FA configurado +admin-mfa-enrolled-since = Configurado desde +admin-mfa-reenroll-note = Para cambiar de aplicación, pide a un administrador que restablezca tu 2FA y vuelve a configurarlo. +admin-mfa-not-enrolled = 2FA no configurado +admin-mfa-pending-note = Hay una configuración incompleta. Introduce tu contraseña para comenzar de nuevo con una clave nueva. +admin-mfa-current-password = Contraseña actual +admin-mfa-start = Configurar 2FA +admin-mfa-setup-title = Vincula tu aplicación de autenticación +admin-mfa-setup-help = Escanea el código QR en la aplicación e introduce el código de 6 dígitos que genera. +admin-mfa-error-rate = Demasiados intentos incorrectos. Espera un minuto e inténtalo de nuevo. +admin-mfa-error-code = Código incorrecto o caducado. Comprueba el reloj del dispositivo e inténtalo de nuevo. +admin-mfa-manual-title = Clave de configuración manual +admin-mfa-manual-help = Si no puedes escanear el QR, introduce esta clave en la aplicación de autenticación. +admin-mfa-profile = Perfil: SHA-1, 6 dígitos, periodo de 30 segundos. +admin-mfa-code-label = Código de 6 dígitos +admin-mfa-confirm = Confirmar y activar +admin-mfa-recovery-title = Guarda tus códigos de recuperación +admin-mfa-recovery-warning = Estos códigos se muestran una sola vez. Cópialos o guárdalos ahora en un lugar seguro. +admin-mfa-recovery-help = Cada código puede usarse una sola vez si pierdes acceso a la aplicación de autenticación. +admin-mfa-continue = Continuar +admin-users-mfa-section = Autenticación de dos factores +admin-users-mfa-configured = 2FA configurado desde +admin-users-mfa-reset-hint = El restablecimiento elimina la clave y todos los códigos de recuperación. El usuario deberá configurar 2FA de nuevo. +admin-users-mfa-reset-confirm = ¿Restablecer el 2FA de este usuario? La clave y TODOS los códigos de recuperación se eliminarán inmediatamente. +admin-users-mfa-reset = Restablecer 2FA +admin-users-mfa-not-configured = 2FA no configurado +admin-users-mfa-reset-ok = Se restablecieron el 2FA y sus códigos de recuperación. diff --git a/crates/ruscker-admin/assets/i18n/fr/landing.ftl b/crates/ruscker-admin/assets/i18n/fr/landing.ftl index 0f0fb83a..b1b9650c 100644 --- a/crates/ruscker-admin/assets/i18n/fr/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/fr/landing.ftl @@ -993,3 +993,39 @@ admin-schedules-flash-toggled = Planification mise à jour. admin-schedules-flash-bad-cron = Expression cron invalide — utilisez la forme à 5 champs, p. ex. "0 3 * * *". admin-schedules-flash-bad-spec = App inconnue, ou l'app n'a pas d'image de conteneur à exécuter. admin-schedules-flash-error = L'opération a échoué — consultez les logs du serveur. + +# — TOTP / authentification à deux facteurs (#1005) +chrome-mfa = Authentification à deux facteurs +admin-mfa-title = Authentification à deux facteurs +admin-mfa-help = Protégez votre compte avec les codes temporaires d’une application d’authentification. +admin-mfa-error-password = Votre mot de passe actuel est incorrect. +admin-mfa-error-key = RUSCKER_MASTER_KEY n’est pas configurée. Configurez-la et redémarrez Ruscker avant d’inscrire la 2FA. +admin-mfa-break-glass = Les sessions d’urgence par jeton n’ont ni compte ni mot de passe et ne peuvent pas configurer la 2FA. Connectez-vous avec un nom d’utilisateur et un mot de passe. +admin-mfa-already = La 2FA est déjà configurée. Un administrateur doit la réinitialiser avant une nouvelle inscription. +admin-mfa-enrolled = 2FA configurée +admin-mfa-enrolled-since = Configurée depuis +admin-mfa-reenroll-note = Pour changer d’application, demandez à un administrateur de réinitialiser votre 2FA, puis configurez-la à nouveau. +admin-mfa-not-enrolled = 2FA non configurée +admin-mfa-pending-note = Une inscription est incomplète. Saisissez votre mot de passe pour recommencer avec une nouvelle clé. +admin-mfa-current-password = Mot de passe actuel +admin-mfa-start = Configurer la 2FA +admin-mfa-setup-title = Associez votre application d’authentification +admin-mfa-setup-help = Scannez le code QR dans l’application, puis saisissez le code à 6 chiffres qu’elle génère. +admin-mfa-error-rate = Trop de tentatives incorrectes. Attendez une minute et réessayez. +admin-mfa-error-code = Code incorrect ou expiré. Vérifiez l’heure de l’appareil et réessayez. +admin-mfa-manual-title = Clé de configuration manuelle +admin-mfa-manual-help = Si vous ne pouvez pas scanner le QR, saisissez cette clé dans l’application d’authentification. +admin-mfa-profile = Profil : SHA-1, 6 chiffres, période de 30 secondes. +admin-mfa-code-label = Code à 6 chiffres +admin-mfa-confirm = Confirmer et activer +admin-mfa-recovery-title = Enregistrez vos codes de récupération +admin-mfa-recovery-warning = Ces codes ne sont affichés qu’une seule fois. Copiez-les ou conservez-les maintenant dans un lieu sûr. +admin-mfa-recovery-help = Chaque code ne peut être utilisé qu’une fois si vous perdez l’accès à l’application d’authentification. +admin-mfa-continue = Continuer +admin-users-mfa-section = Authentification à deux facteurs +admin-users-mfa-configured = 2FA configurée depuis +admin-users-mfa-reset-hint = La réinitialisation supprime la clé et tous les codes de récupération. L’utilisateur devra configurer à nouveau la 2FA. +admin-users-mfa-reset-confirm = Réinitialiser la 2FA de cet utilisateur ? La clé et TOUS les codes de récupération seront supprimés immédiatement. +admin-users-mfa-reset = Réinitialiser la 2FA +admin-users-mfa-not-configured = 2FA non configurée +admin-users-mfa-reset-ok = La 2FA et ses codes de récupération ont été réinitialisés. diff --git a/crates/ruscker-admin/assets/i18n/pt/landing.ftl b/crates/ruscker-admin/assets/i18n/pt/landing.ftl index a759d897..d62f0871 100644 --- a/crates/ruscker-admin/assets/i18n/pt/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/pt/landing.ftl @@ -997,3 +997,39 @@ admin-schedules-flash-toggled = Agendamento atualizado. admin-schedules-flash-bad-cron = Expressão cron inválida — use a forma de 5 campos, ex.: "0 3 * * *". admin-schedules-flash-bad-spec = App desconhecido, ou o app não tem imagem de container para executar. admin-schedules-flash-error = A operação falhou — verifique os logs do servidor. + +# — TOTP / autenticação em dois fatores (#1005) +chrome-mfa = Autenticação em dois fatores +admin-mfa-title = Autenticação em dois fatores +admin-mfa-help = Proteja sua conta com códigos temporários de um aplicativo autenticador. +admin-mfa-error-password = A senha atual está incorreta. +admin-mfa-error-key = RUSCKER_MASTER_KEY não está configurada. Configure-a e reinicie o Ruscker antes de cadastrar o 2FA. +admin-mfa-break-glass = Sessões de emergência por token não têm uma conta nem senha e não podem configurar 2FA. Entre com usuário e senha. +admin-mfa-already = O 2FA já está configurado. Um administrador precisa redefini-lo antes de um novo cadastro. +admin-mfa-enrolled = 2FA configurado +admin-mfa-enrolled-since = Configurado desde +admin-mfa-reenroll-note = Para trocar de aplicativo autenticador, peça a um administrador para redefinir seu 2FA e cadastre-o novamente. +admin-mfa-not-enrolled = 2FA não configurado +admin-mfa-pending-note = Há um cadastro incompleto. Informe sua senha para começar novamente com uma nova chave. +admin-mfa-current-password = Senha atual +admin-mfa-start = Configurar 2FA +admin-mfa-setup-title = Vincule seu aplicativo autenticador +admin-mfa-setup-help = Leia o QR code no aplicativo e informe abaixo o código de 6 dígitos gerado por ele. +admin-mfa-error-rate = Muitas tentativas incorretas. Aguarde um minuto e tente novamente. +admin-mfa-error-code = Código incorreto ou expirado. Confira o relógio do dispositivo e tente novamente. +admin-mfa-manual-title = Chave para cadastro manual +admin-mfa-manual-help = Se não puder ler o QR code, digite esta chave no aplicativo autenticador. +admin-mfa-profile = Perfil: SHA-1, 6 dígitos, período de 30 segundos. +admin-mfa-code-label = Código de 6 dígitos +admin-mfa-confirm = Confirmar e ativar +admin-mfa-recovery-title = Salve seus códigos de recuperação +admin-mfa-recovery-warning = Estes códigos aparecem uma única vez. Copie ou guarde-os agora em um local seguro. +admin-mfa-recovery-help = Cada código pode ser usado somente uma vez caso você perca acesso ao aplicativo autenticador. +admin-mfa-continue = Continuar +admin-users-mfa-section = Autenticação em dois fatores +admin-users-mfa-configured = 2FA configurado desde +admin-users-mfa-reset-hint = A redefinição apaga a chave e todos os códigos de recuperação. O usuário precisará cadastrar o 2FA novamente. +admin-users-mfa-reset-confirm = Redefinir o 2FA deste usuário? A chave e TODOS os códigos de recuperação serão apagados imediatamente. +admin-users-mfa-reset = Redefinir 2FA +admin-users-mfa-not-configured = 2FA não configurado +admin-users-mfa-reset-ok = O 2FA e os códigos de recuperação foram redefinidos. diff --git a/crates/ruscker-admin/migrations-pg/0029_user_mfa.sql b/crates/ruscker-admin/migrations-pg/0029_user_mfa.sql new file mode 100644 index 00000000..06f1246a --- /dev/null +++ b/crates/ruscker-admin/migrations-pg/0029_user_mfa.sql @@ -0,0 +1,26 @@ +-- Postgres twin of migrations/0029_user_mfa.sql (#1005 slice 2). +CREATE TABLE user_mfa ( + username TEXT PRIMARY KEY REFERENCES users(username) ON DELETE CASCADE, + secret_enc BYTEA NOT NULL, + secret_nonce BYTEA NOT NULL, + -- Random per-enrollment ceremony token: binds the pending secret to + -- the browser that passed the password re-auth (cookie) and makes the + -- confirm UPDATE conditional, so a racing re-start can never get its + -- replacement secret confirmed by a code proving the old one. + ceremony TEXT NOT NULL, + confirmed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + -- Reserved for slice 3's TOTP replay prevention. + last_used_step BIGINT +); + +CREATE TABLE user_mfa_recovery ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX idx_user_mfa_recovery_username + ON user_mfa_recovery(username, used_at); diff --git a/crates/ruscker-admin/migrations/0029_user_mfa.sql b/crates/ruscker-admin/migrations/0029_user_mfa.sql new file mode 100644 index 00000000..1a6e890d --- /dev/null +++ b/crates/ruscker-admin/migrations/0029_user_mfa.sql @@ -0,0 +1,29 @@ +-- User-owned TOTP factors and one-time recovery codes (#1005 slice 2). +-- `confirmed_at IS NULL` is a pending enrollment awaiting its first valid +-- authenticator code. Secrets are AES-256-GCM ciphertext + nonce; plaintext +-- never reaches the database. +CREATE TABLE user_mfa ( + username TEXT PRIMARY KEY REFERENCES users(username) ON DELETE CASCADE, + secret_enc BLOB NOT NULL, + secret_nonce BLOB NOT NULL, + -- Random per-enrollment ceremony token: binds the pending secret to + -- the browser that passed the password re-auth (cookie) and makes the + -- confirm UPDATE conditional, so a racing re-start can never get its + -- replacement secret confirmed by a code proving the old one. + ceremony TEXT NOT NULL, + confirmed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + -- Reserved for slice 3's TOTP replay prevention. + last_used_step BIGINT +); + +CREATE TABLE user_mfa_recovery ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX idx_user_mfa_recovery_username + ON user_mfa_recovery(username, used_at); diff --git a/crates/ruscker-admin/src/db.rs b/crates/ruscker-admin/src/db.rs index e8fb557c..dc353d3c 100644 --- a/crates/ruscker-admin/src/db.rs +++ b/crates/ruscker-admin/src/db.rs @@ -164,6 +164,7 @@ pub mod export; pub mod images; pub mod landing; pub mod landing_blocks; +pub mod mfa; pub mod ruscker_images; pub mod schedules; pub mod settings; @@ -250,6 +251,8 @@ mod pg_tests { "landing_customization", "spec_versions", "specs", + "user_mfa", + "user_mfa_recovery", "users", ] { assert!(present.contains(expected), "missing table: {expected}"); diff --git a/crates/ruscker-admin/src/db/mfa.rs b/crates/ruscker-admin/src/db/mfa.rs new file mode 100644 index 00000000..7795dfbe --- /dev/null +++ b/crates/ruscker-admin/src/db/mfa.rs @@ -0,0 +1,609 @@ +//! Persistent TOTP enrollment state and one-time recovery codes. +//! +//! The repository stores only AES-GCM ciphertext/nonce for the TOTP secret. +//! Decryption belongs to the route layer, where `AppState::master_key` lives. + +use anyhow::{bail, Context, Result}; +use chrono::{DateTime, Utc}; + +use crate::db::ConfigDb; + +#[derive(Debug, Clone)] +pub struct MfaRow { + pub username: String, + pub secret_enc: Vec, + pub secret_nonce: Vec, + /// Random per-enrollment token binding the pending secret to the + /// browser that passed the password re-auth (codex review, #1005): + /// confirm requires it both to re-render the secret and in the + /// conditional UPDATE, so neither a hijacked same-user session nor a + /// racing re-start can act on a ceremony it didn't begin. + pub ceremony: String, + pub confirmed_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + /// Reserved for slice 3's accepted-step replay prevention. + pub last_used_step: Option, +} + +type StoredRow = ( + String, + Vec, + Vec, + String, + Option>, + DateTime, + DateTime, + Option, +); + +/// Start or replace a pending enrollment. A confirmed factor is never +/// overwritten; administrators must reset it explicitly before re-enrollment. +pub async fn begin_enrollment( + db: &ConfigDb, + username: &str, + secret_enc: &[u8], + nonce: &[u8], + ceremony: &str, +) -> Result<()> { + let username = crate::db::users::normalize_username(username); + let now = Utc::now(); + let affected = match db { + ConfigDb::Sqlite(pool) => sqlx::query( + "INSERT INTO user_mfa + (username, secret_enc, secret_nonce, ceremony, confirmed_at, created_at, updated_at, last_used_step) + VALUES (?, ?, ?, ?, NULL, ?, ?, NULL) + ON CONFLICT(username) DO UPDATE SET + secret_enc = excluded.secret_enc, + secret_nonce = excluded.secret_nonce, + ceremony = excluded.ceremony, + confirmed_at = NULL, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + last_used_step = NULL + WHERE user_mfa.confirmed_at IS NULL", + ) + .bind(&username) + .bind(secret_enc) + .bind(nonce) + .bind(ceremony) + .bind(now) + .bind(now) + .execute(pool) + .await + .with_context(|| format!("begin MFA enrollment for {username}"))? + .rows_affected(), + ConfigDb::Postgres(pool) => sqlx::query( + "INSERT INTO user_mfa + (username, secret_enc, secret_nonce, ceremony, confirmed_at, created_at, updated_at, last_used_step) + VALUES ($1, $2, $3, $4, NULL, $5, $6, NULL) + ON CONFLICT(username) DO UPDATE SET + secret_enc = excluded.secret_enc, + secret_nonce = excluded.secret_nonce, + ceremony = excluded.ceremony, + confirmed_at = NULL, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + last_used_step = NULL + WHERE user_mfa.confirmed_at IS NULL", + ) + .bind(&username) + .bind(secret_enc) + .bind(nonce) + .bind(ceremony) + .bind(now) + .bind(now) + .execute(pool) + .await + .with_context(|| format!("begin MFA enrollment for {username}"))? + .rows_affected(), + }; + if affected == 0 { + bail!("MFA is already confirmed for {username}; reset it before re-enrolling"); + } + Ok(()) +} + +/// Mark a pending factor confirmed and atomically audit `mfa.enroll`. +pub async fn confirm_enrollment( + db: &ConfigDb, + username: &str, + actor: &str, + ceremony: &str, +) -> Result<()> { + confirm_with_recovery_codes(db, username, actor, None, ceremony).await +} + +/// Confirm enrollment, replace the recovery-code hashes, and write the audit +/// row in one transaction. The conditional pending-row update is the HA-safe +/// winner election when two confirm requests race: exactly one can commit and +/// show codes that match the stored hashes. +pub async fn confirm_with_recovery_codes( + db: &ConfigDb, + username: &str, + actor: &str, + recovery_hashes: Option<&[String]>, + ceremony: &str, +) -> Result<()> { + let username = crate::db::users::normalize_username(username); + let target = format!("user:{username}"); + let now = Utc::now(); + match db { + ConfigDb::Sqlite(pool) => { + let mut tx = pool.begin().await.context("begin MFA confirm")?; + let result = sqlx::query( + "UPDATE user_mfa + SET confirmed_at = ?, updated_at = ? + WHERE username = ? AND confirmed_at IS NULL AND ceremony = ?", + ) + .bind(now) + .bind(now) + .bind(&username) + .bind(ceremony) + .execute(&mut *tx) + .await + .context("confirm MFA enrollment")?; + if result.rows_affected() == 0 { + bail!("pending MFA enrollment not found for {username}"); + } + if let Some(hashes) = recovery_hashes { + sqlx::query("DELETE FROM user_mfa_recovery WHERE username = ?") + .bind(&username) + .execute(&mut *tx) + .await + .context("delete old recovery codes during confirm")?; + for hash in hashes { + sqlx::query( + "INSERT INTO user_mfa_recovery + (id, username, code_hash, used_at, created_at) + VALUES (?, ?, ?, NULL, ?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&username) + .bind(hash) + .bind(now) + .execute(&mut *tx) + .await + .context("insert recovery code during confirm")?; + } + } + sqlx::query( + "INSERT INTO audit_log (actor, action, target, diff_json, occurred_at) + VALUES (?, 'mfa.enroll', ?, NULL, ?)", + ) + .bind(actor) + .bind(&target) + .bind(now) + .execute(&mut *tx) + .await + .context("audit MFA enrollment")?; + tx.commit().await.context("commit MFA confirm")?; + } + ConfigDb::Postgres(pool) => { + let mut tx = pool.begin().await.context("begin MFA confirm")?; + let result = sqlx::query( + "UPDATE user_mfa + SET confirmed_at = $1, updated_at = $2 + WHERE username = $3 AND confirmed_at IS NULL AND ceremony = $4", + ) + .bind(now) + .bind(now) + .bind(&username) + .bind(ceremony) + .execute(&mut *tx) + .await + .context("confirm MFA enrollment")?; + if result.rows_affected() == 0 { + bail!("pending MFA enrollment not found for {username}"); + } + if let Some(hashes) = recovery_hashes { + sqlx::query("DELETE FROM user_mfa_recovery WHERE username = $1") + .bind(&username) + .execute(&mut *tx) + .await + .context("delete old recovery codes during confirm")?; + for hash in hashes { + sqlx::query( + "INSERT INTO user_mfa_recovery + (id, username, code_hash, used_at, created_at) + VALUES ($1, $2, $3, NULL, $4)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&username) + .bind(hash) + .bind(now) + .execute(&mut *tx) + .await + .context("insert recovery code during confirm")?; + } + } + sqlx::query( + "INSERT INTO audit_log (actor, action, target, diff_json, occurred_at) + VALUES ($1, 'mfa.enroll', $2, NULL, $3)", + ) + .bind(actor) + .bind(&target) + .bind(now) + .execute(&mut *tx) + .await + .context("audit MFA enrollment")?; + tx.commit().await.context("commit MFA confirm")?; + } + } + Ok(()) +} + +pub async fn fetch(db: &ConfigDb, username: &str) -> Result> { + let username = crate::db::users::normalize_username(username); + let row: Option = match db { + ConfigDb::Sqlite(pool) => { + sqlx::query_as( + "SELECT username, secret_enc, secret_nonce, ceremony, confirmed_at, + created_at, updated_at, last_used_step + FROM user_mfa WHERE username = ?", + ) + .bind(&username) + .fetch_optional(pool) + .await + } + ConfigDb::Postgres(pool) => { + sqlx::query_as( + "SELECT username, secret_enc, secret_nonce, ceremony, confirmed_at, + created_at, updated_at, last_used_step + FROM user_mfa WHERE username = $1", + ) + .bind(&username) + .fetch_optional(pool) + .await + } + } + .with_context(|| format!("fetch MFA state for {username}"))?; + Ok(row.map( + |( + username, + secret_enc, + secret_nonce, + ceremony, + confirmed_at, + created_at, + updated_at, + last_used_step, + )| { + MfaRow { + username, + secret_enc, + secret_nonce, + ceremony, + confirmed_at, + created_at, + updated_at, + last_used_step, + } + }, + )) +} + +/// Replace every recovery code for a user. Callers pass salted hashes only; +/// plaintext codes are never accepted by this repository. +pub async fn replace_recovery_codes( + db: &ConfigDb, + username: &str, + hashes: &[String], +) -> Result<()> { + let username = crate::db::users::normalize_username(username); + let now = Utc::now(); + match db { + ConfigDb::Sqlite(pool) => { + let mut tx = pool.begin().await.context("begin recovery replacement")?; + sqlx::query("DELETE FROM user_mfa_recovery WHERE username = ?") + .bind(&username) + .execute(&mut *tx) + .await + .context("delete old recovery codes")?; + for hash in hashes { + sqlx::query( + "INSERT INTO user_mfa_recovery + (id, username, code_hash, used_at, created_at) + VALUES (?, ?, ?, NULL, ?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&username) + .bind(hash) + .bind(now) + .execute(&mut *tx) + .await + .context("insert recovery code")?; + } + tx.commit().await.context("commit recovery replacement")?; + } + ConfigDb::Postgres(pool) => { + let mut tx = pool.begin().await.context("begin recovery replacement")?; + sqlx::query("DELETE FROM user_mfa_recovery WHERE username = $1") + .bind(&username) + .execute(&mut *tx) + .await + .context("delete old recovery codes")?; + for hash in hashes { + sqlx::query( + "INSERT INTO user_mfa_recovery + (id, username, code_hash, used_at, created_at) + VALUES ($1, $2, $3, NULL, $4)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&username) + .bind(hash) + .bind(now) + .execute(&mut *tx) + .await + .context("insert recovery code")?; + } + tx.commit().await.context("commit recovery replacement")?; + } + } + Ok(()) +} + +/// Consume a matching unused recovery code exactly once. At most ten hashes +/// are checked; every digest comparison is constant-time in `crate::mfa`. +pub async fn consume_recovery_code(db: &ConfigDb, username: &str, code: &str) -> Result { + let username = crate::db::users::normalize_username(username); + let now = Utc::now(); + match db { + ConfigDb::Sqlite(pool) => { + let mut tx = pool.begin().await.context("begin recovery consumption")?; + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT id, code_hash FROM user_mfa_recovery + WHERE username = ? AND used_at IS NULL", + ) + .bind(&username) + .fetch_all(&mut *tx) + .await + .context("fetch recovery hashes")?; + let Some(id) = rows + .iter() + .find(|(_, hash)| crate::mfa::verify_recovery_code(code, hash)) + .map(|(id, _)| id) + else { + let _ = tx.rollback().await; + return Ok(false); + }; + let changed = sqlx::query( + "UPDATE user_mfa_recovery SET used_at = ? + WHERE id = ? AND used_at IS NULL", + ) + .bind(now) + .bind(id) + .execute(&mut *tx) + .await + .context("consume recovery code")? + .rows_affected(); + tx.commit().await.context("commit recovery consumption")?; + Ok(changed == 1) + } + ConfigDb::Postgres(pool) => { + let mut tx = pool.begin().await.context("begin recovery consumption")?; + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT id, code_hash FROM user_mfa_recovery + WHERE username = $1 AND used_at IS NULL", + ) + .bind(&username) + .fetch_all(&mut *tx) + .await + .context("fetch recovery hashes")?; + let Some(id) = rows + .iter() + .find(|(_, hash)| crate::mfa::verify_recovery_code(code, hash)) + .map(|(id, _)| id) + else { + let _ = tx.rollback().await; + return Ok(false); + }; + let changed = sqlx::query( + "UPDATE user_mfa_recovery SET used_at = $1 + WHERE id = $2 AND used_at IS NULL", + ) + .bind(now) + .bind(id) + .execute(&mut *tx) + .await + .context("consume recovery code")? + .rows_affected(); + tx.commit().await.context("commit recovery consumption")?; + Ok(changed == 1) + } + } +} + +pub async fn is_enrolled(db: &ConfigDb, username: &str) -> Result { + let username = crate::db::users::normalize_username(username); + let (exists,): (bool,) = match db { + ConfigDb::Sqlite(pool) => { + sqlx::query_as( + "SELECT EXISTS(SELECT 1 FROM user_mfa + WHERE username = ? AND confirmed_at IS NOT NULL)", + ) + .bind(&username) + .fetch_one(pool) + .await + } + ConfigDb::Postgres(pool) => { + sqlx::query_as( + "SELECT EXISTS(SELECT 1 FROM user_mfa + WHERE username = $1 AND confirmed_at IS NOT NULL)", + ) + .bind(&username) + .fetch_one(pool) + .await + } + } + .with_context(|| format!("read MFA status for {username}"))?; + Ok(exists) +} + +/// Delete the factor and every recovery code, then audit `mfa.reset` in the +/// same transaction. This is shared by the admin UI and later MFA slices. +pub async fn reset(db: &ConfigDb, username: &str, actor: &str) -> Result<()> { + let username = crate::db::users::normalize_username(username); + let target = format!("user:{username}"); + let now = Utc::now(); + match db { + ConfigDb::Sqlite(pool) => { + let mut tx = pool.begin().await.context("begin MFA reset")?; + sqlx::query("DELETE FROM user_mfa_recovery WHERE username = ?") + .bind(&username) + .execute(&mut *tx) + .await + .context("delete recovery codes")?; + sqlx::query("DELETE FROM user_mfa WHERE username = ?") + .bind(&username) + .execute(&mut *tx) + .await + .context("delete MFA factor")?; + sqlx::query( + "INSERT INTO audit_log (actor, action, target, diff_json, occurred_at) + VALUES (?, 'mfa.reset', ?, NULL, ?)", + ) + .bind(actor) + .bind(&target) + .bind(now) + .execute(&mut *tx) + .await + .context("audit MFA reset")?; + tx.commit().await.context("commit MFA reset")?; + } + ConfigDb::Postgres(pool) => { + let mut tx = pool.begin().await.context("begin MFA reset")?; + sqlx::query("DELETE FROM user_mfa_recovery WHERE username = $1") + .bind(&username) + .execute(&mut *tx) + .await + .context("delete recovery codes")?; + sqlx::query("DELETE FROM user_mfa WHERE username = $1") + .bind(&username) + .execute(&mut *tx) + .await + .context("delete MFA factor")?; + sqlx::query( + "INSERT INTO audit_log (actor, action, target, diff_json, occurred_at) + VALUES ($1, 'mfa.reset', $2, NULL, $3)", + ) + .bind(actor) + .bind(&target) + .bind(now) + .execute(&mut *tx) + .await + .context("audit MFA reset")?; + tx.commit().await.context("commit MFA reset")?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::Role; + + async fn db_with_user(username: &str) -> ConfigDb { + let pool = crate::db::open_memory().await.unwrap(); + let db = ConfigDb::Sqlite(pool); + crate::db::users::create( + &db, + username, + "Correct#123", + Role::Viewer, + false, + &[], + Some("test"), + ) + .await + .unwrap(); + db + } + + #[tokio::test] + async fn confirmed_factor_cannot_be_overwritten_without_reset() { + let db = db_with_user("alice").await; + begin_enrollment(&db, "alice", b"cipher", b"nonce", "cer-1") + .await + .unwrap(); + confirm_enrollment(&db, "alice", "alice", "cer-1").await.unwrap(); + assert!(begin_enrollment(&db, "alice", b"new", b"nonce", "cer-2") + .await + .is_err()); + } + + #[tokio::test] + async fn recovery_code_consumes_once() { + let db = db_with_user("alice").await; + let hash = crate::mfa::hash_recovery_code("ABCD234567").unwrap(); + replace_recovery_codes(&db, "alice", &[hash]).await.unwrap(); + assert!(consume_recovery_code(&db, "alice", "abcd234567") + .await + .unwrap()); + assert!(!consume_recovery_code(&db, "alice", "ABCD234567") + .await + .unwrap()); + } + + #[tokio::test] + async fn reset_removes_factor_and_codes_and_audits() { + let db = db_with_user("alice").await; + begin_enrollment(&db, "alice", b"cipher", b"nonce", "cer-1") + .await + .unwrap(); + replace_recovery_codes( + &db, + "alice", + &[crate::mfa::hash_recovery_code("ABCD234567").unwrap()], + ) + .await + .unwrap(); + reset(&db, "alice", "root").await.unwrap(); + assert!(fetch(&db, "alice").await.unwrap().is_none()); + let filter = crate::db::audit::AuditFilter { + actor: Some("root".to_string()), + ..crate::db::audit::AuditFilter::new() + }; + let audit = crate::db::audit::list(&db, &filter).await.unwrap(); + assert!(audit.iter().any(|row| row.action == "mfa.reset")); + } + + #[cfg(feature = "postgres-it")] + #[tokio::test] + async fn postgres_begin_confirm_reset_round_trip() { + let _guard = crate::db::pg_test_lock().lock().await; + let url = std::env::var("RUSCKER_TEST_PG_URL") + .expect("set RUSCKER_TEST_PG_URL to a reachable postgres:// DSN"); + let pool = crate::db::open_pg(&url).await.unwrap(); + let db = ConfigDb::Postgres(pool); + let username = format!("mfa-pg-{}", uuid::Uuid::new_v4().simple()); + crate::db::users::create( + &db, + &username, + "Correct#123", + Role::Viewer, + false, + &[], + Some("test"), + ) + .await + .unwrap(); + begin_enrollment(&db, &username, b"cipher", b"nonce", "cer-pg") + .await + .unwrap(); + assert!(fetch(&db, &username) + .await + .unwrap() + .unwrap() + .confirmed_at + .is_none()); + confirm_enrollment(&db, &username, &username, "cer-pg").await.unwrap(); + assert!(is_enrolled(&db, &username).await.unwrap()); + reset(&db, &username, "root").await.unwrap(); + assert!(fetch(&db, &username).await.unwrap().is_none()); + crate::db::users::delete(&db, &username, Some("test")) + .await + .unwrap(); + } +} diff --git a/crates/ruscker-admin/src/lib.rs b/crates/ruscker-admin/src/lib.rs index aaa155eb..134805e6 100644 --- a/crates/ruscker-admin/src/lib.rs +++ b/crates/ruscker-admin/src/lib.rs @@ -34,6 +34,7 @@ pub mod leader; pub mod logbuf; pub mod markdown; pub mod metrics_cache; +pub mod mfa; pub mod ratelimit; pub mod routes; pub mod admin_sessions_pg; @@ -899,7 +900,7 @@ async fn must_change_password_guard( use axum::response::IntoResponse; let path = req.uri().path(); let gated = path.starts_with("/admin/") - && !path.starts_with("/admin/account") + && path != "/admin/account/password" && !path.starts_with("/admin/login") && !path.starts_with("/admin/logout") && !path.starts_with("/admin/setup"); diff --git a/crates/ruscker-admin/src/mfa.rs b/crates/ruscker-admin/src/mfa.rs new file mode 100644 index 00000000..b0108266 --- /dev/null +++ b/crates/ruscker-admin/src/mfa.rs @@ -0,0 +1,237 @@ +//! TOTP enrollment primitives and recovery-code handling (#1005 slice 2). +//! +//! TOTP uses HMAC-SHA-1 deliberately: authenticator apps universally support +//! it, RFC 6238 defines it, and SHA-1 collision attacks do not weaken HMAC in +//! this use. Six digits, a 30-second step and one step of skew match the broad +//! authenticator-app interoperability profile. + +use anyhow::{anyhow, Context, Result}; +use qrcode::render::svg; +use qrcode::QrCode; +use ring::{digest, rand as ring_rand}; +use std::collections::{HashMap, VecDeque}; +use std::time::{Duration, Instant}; +use totp_rs::{Algorithm, Secret, TOTP}; + +pub const RECOVERY_CODE_COUNT: usize = 10; +pub const RECOVERY_CODE_LEN: usize = 10; +const RECOVERY_ALPHABET: &[u8; 32] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +/// Material shown on the setup screen and persisted only after encryption. +pub struct Enrollment { + pub secret_base32: String, + pub qr_svg: String, +} + +/// Build the standard Ruscker TOTP profile from a base32 secret. +pub fn totp(secret_base32: &str, username: &str) -> Result { + let secret = Secret::Encoded(secret_base32.to_string()) + .to_bytes() + .map_err(|e| anyhow!("decode TOTP secret: {e}"))?; + TOTP::new( + Algorithm::SHA1, + 6, + 1, + 30, + secret, + Some("Ruscker".to_string()), + username.to_string(), + ) + .map_err(|e| anyhow!("build TOTP profile: {e}")) +} + +/// Generate a fresh 160-bit CSPRNG secret and render its otpauth URL locally +/// as SVG. No secret-bearing request leaves the Ruscker process. +pub fn begin(username: &str) -> Result { + let secret_base32 = Secret::generate_secret().to_encoded().to_string(); + render_enrollment(&secret_base32, username) +} + +/// Re-render an existing pending enrollment after a mistyped confirmation +/// code. This never rotates the secret: the encrypted DB row is authoritative. +pub fn render_enrollment(secret_base32: &str, username: &str) -> Result { + let profile = totp(secret_base32, username)?; + let qr = QrCode::new(profile.get_url().as_bytes()).context("encode TOTP QR")?; + let qr_svg = qr + .render::() + .min_dimensions(240, 240) + .dark_color(svg::Color("#111827")) + .light_color(svg::Color("#ffffff")) + .build(); + Ok(Enrollment { + secret_base32: secret_base32.to_string(), + qr_svg, + }) +} + +pub fn verify_totp(secret_base32: &str, username: &str, code: &str) -> Result { + if code.len() != 6 || !code.bytes().all(|b| b.is_ascii_digit()) { + return Ok(false); + } + totp(secret_base32, username)? + .check_current(code) + .context("read system time for TOTP") +} + +/// Generate ten high-entropy, human-readable one-time recovery codes. +/// The 32-symbol alphabet maps exactly to five random bits, avoiding modulo +/// bias while omitting ambiguous `0/O/1/I` glyphs. +pub fn generate_recovery_codes() -> Result> { + let rng = ring_rand::SystemRandom::new(); + let mut random = [0u8; RECOVERY_CODE_COUNT * RECOVERY_CODE_LEN]; + ring_rand::SecureRandom::fill(&rng, &mut random) + .map_err(|_| anyhow!("generate recovery codes"))?; + Ok(random + .chunks_exact(RECOVERY_CODE_LEN) + .map(|chunk| { + chunk + .iter() + .map(|b| RECOVERY_ALPHABET[(b & 31) as usize] as char) + .collect() + }) + .collect()) +} + +/// Hash a high-entropy recovery code with a fresh random salt. +/// +/// These server-generated codes carry roughly 50 bits of entropy and are +/// rate-limited when consumed. Salted SHA-256 is therefore sufficient and +/// keeps each check O(1); password-oriented Argon2 is deliberately reserved +/// for low-entropy, user-chosen passwords. +pub fn hash_recovery_code(code: &str) -> Result { + let rng = ring_rand::SystemRandom::new(); + let mut salt = [0u8; 16]; + ring_rand::SecureRandom::fill(&rng, &mut salt) + .map_err(|_| anyhow!("generate recovery-code salt"))?; + let normalized = code.trim().to_ascii_uppercase(); + let mut input = Vec::with_capacity(salt.len() + normalized.len()); + input.extend_from_slice(&salt); + input.extend_from_slice(normalized.as_bytes()); + let hash = digest::digest(&digest::SHA256, &input); + Ok(format!( + "{}:{}", + hex::encode(salt), + hex::encode(hash.as_ref()) + )) +} + +/// Constant-time verification against a stored salted SHA-256 value. +pub fn verify_recovery_code(code: &str, stored: &str) -> bool { + let Some((salt_hex, hash_hex)) = stored.split_once(':') else { + return false; + }; + let (Ok(salt), Ok(expected)) = (hex::decode(salt_hex), hex::decode(hash_hex)) else { + return false; + }; + if salt.len() != 16 || expected.len() != digest::SHA256_OUTPUT_LEN { + return false; + } + let normalized = code.trim().to_ascii_uppercase(); + let mut input = Vec::with_capacity(salt.len() + normalized.len()); + input.extend_from_slice(&salt); + input.extend_from_slice(normalized.as_bytes()); + let actual = digest::digest(&digest::SHA256, &input); + constant_time_equal(actual.as_ref(), &expected) +} + +#[allow(deprecated)] +fn constant_time_equal(actual: &[u8], expected: &[u8]) -> bool { + ring::constant_time::verify_slices_are_equal(actual, expected).is_ok() +} + +/// Per-username confirmation limiter. A correct code clears that username's +/// failures; five wrong codes inside 60 seconds produce a friendly 429. +#[derive(Debug)] +pub struct ConfirmRateLimiter { + failures: std::sync::Mutex>>, + max: usize, + window: Duration, +} + +impl ConfirmRateLimiter { + pub fn new(max: usize, window: Duration) -> Self { + Self { + failures: std::sync::Mutex::new(HashMap::new()), + max, + window, + } + } + + /// Atomically reserve one attempt: prune the window, and either count + /// this attempt and allow it, or refuse. Reserving BEFORE the awaited + /// argon2/TOTP verification closes the TOCTOU where N concurrent + /// requests all pass a read-only check ahead of any failure being + /// recorded (codex review, #1005) — the reservation itself is the + /// count, under one mutex critical section. A success then clears the + /// whole entry, so legitimate flows never accumulate. + pub fn try_reserve(&self, username: &str) -> bool { + let now = Instant::now(); + let mut all = self.failures.lock().unwrap(); + let attempts = all.entry(username.to_string()).or_default(); + while attempts + .front() + .is_some_and(|at| now.duration_since(*at) > self.window) + { + attempts.pop_front(); + } + if attempts.len() >= self.max { + return false; + } + attempts.push_back(now); + true + } + + pub fn record_success(&self, username: &str) { + self.failures.lock().unwrap().remove(username); + } +} + +pub static CONFIRM_LIMITER: std::sync::LazyLock = + std::sync::LazyLock::new(|| ConfirmRateLimiter::new(5, Duration::from_secs(60))); + +/// Per-username limiter for the password re-authentication at /start +/// (codex review, #1005): a stolen session cookie must not turn the +/// enrollment page into an unlimited password oracle (each guess runs a +/// deliberately-expensive argon2 verify). Same shape as the code limiter. +pub static REAUTH_LIMITER: std::sync::LazyLock = + std::sync::LazyLock::new(|| ConfirmRateLimiter::new(5, Duration::from_secs(60))); + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::MasterKey; + + #[test] + fn secret_encrypt_decrypt_round_trip() { + let key = MasterKey::parse(&"ab".repeat(32)).unwrap(); + let enrollment = begin("alice").unwrap(); + let (ciphertext, nonce) = key.encrypt(enrollment.secret_base32.as_bytes()).unwrap(); + assert_ne!(ciphertext, enrollment.secret_base32.as_bytes()); + let plaintext = key.decrypt(&ciphertext, &nonce).unwrap(); + assert_eq!(plaintext.as_slice(), enrollment.secret_base32.as_bytes()); + } + + #[test] + fn totp_accepts_current_and_adjacent_steps_but_rejects_garbage() { + let secret = Secret::generate_secret().to_encoded().to_string(); + let profile = totp(&secret, "alice").unwrap(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + assert!(profile.check(&profile.generate(now), now)); + assert!(profile.check(&profile.generate(now - 30), now)); + assert!(profile.check(&profile.generate(now + 30), now)); + assert!(!profile.check("garbage", now)); + assert!(!profile.check("0000000", now)); + } + + #[test] + fn recovery_hash_is_salted_and_verifies_constant_time() { + let first = hash_recovery_code("ABCD234567").unwrap(); + let second = hash_recovery_code("ABCD234567").unwrap(); + assert_ne!(first, second); + assert!(verify_recovery_code("abcd234567", &first)); + assert!(!verify_recovery_code("ABCD234568", &first)); + } +} diff --git a/crates/ruscker-admin/src/routes/admin.rs b/crates/ruscker-admin/src/routes/admin.rs index 1ececd03..2e3b1905 100644 --- a/crates/ruscker-admin/src/routes/admin.rs +++ b/crates/ruscker-admin/src/routes/admin.rs @@ -31,6 +31,7 @@ pub mod groups; pub mod images; pub mod landing; pub mod logs; +pub mod mfa; pub mod schedules_ui; pub mod spec_form; pub mod specs; @@ -53,6 +54,7 @@ pub fn routes() -> Router { "/admin/account/password", get(password_form).post(password_submit), ) + .merge(mfa::routes()) .merge(dashboard::routes()) .merge(disk::routes()) .merge(specs::routes()) diff --git a/crates/ruscker-admin/src/routes/admin/mfa.rs b/crates/ruscker-admin/src/routes/admin/mfa.rs new file mode 100644 index 00000000..7077c20c --- /dev/null +++ b/crates/ruscker-admin/src/routes/admin/mfa.rs @@ -0,0 +1,572 @@ +//! Self-service TOTP enrollment for every password-backed account. + +use askama::Template; +use axum::{ + extract::{Form, Query, State}, + http::{header::CACHE_CONTROL, header::RETRY_AFTER, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Router, +}; +use serde::Deserialize; + +use crate::auth::{AdminSession, Role}; +use axum::http::HeaderMap; +use tower_cookies::{Cookie, Cookies}; +use crate::db; +use crate::i18n::{Locale, Locales}; +use crate::theme::Theme; +use crate::AppState; + +pub fn routes() -> Router { + Router::new() + .route("/admin/account/mfa", get(status)) + .route("/admin/account/mfa/start", post(start)) + .route("/admin/account/mfa/confirm", post(confirm)) +} + +#[derive(Template)] +#[template(path = "admin/account_mfa.html")] +struct AccountMfaPage<'a> { + locale: Locale, + theme: Theme, + locales: &'a Locales, + locales_all: &'static [Locale], + base: std::sync::Arc, + nav_section: &'static str, + role: Role, + enrolled_at: String, + pending: bool, + break_glass: bool, + error: &'static str, + next: String, +} + +impl AccountMfaPage<'_> { + fn t(&self, key: &str) -> String { + self.locales.t(self.locale, key, None) + } +} + +#[derive(Template)] +#[template(path = "admin/account_mfa_setup.html")] +struct AccountMfaSetupPage<'a> { + locale: Locale, + theme: Theme, + locales: &'a Locales, + locales_all: &'static [Locale], + base: std::sync::Arc, + nav_section: &'static str, + role: Role, + qr_svg: String, + secret: String, + error: &'static str, + next: String, +} + +impl AccountMfaSetupPage<'_> { + fn t(&self, key: &str) -> String { + self.locales.t(self.locale, key, None) + } +} + +#[derive(Template)] +#[template(path = "admin/account_mfa_recovery.html")] +struct AccountMfaRecoveryPage<'a> { + locale: Locale, + theme: Theme, + locales: &'a Locales, + locales_all: &'static [Locale], + base: std::sync::Arc, + nav_section: &'static str, + role: Role, + codes: Vec, + next: String, +} + +impl AccountMfaRecoveryPage<'_> { + fn t(&self, key: &str) -> String { + self.locales.t(self.locale, key, None) + } +} + +#[derive(Debug, Deserialize, Default)] +struct NextQuery { + next: Option, +} + +#[derive(Debug, Deserialize)] +struct StartForm { + current_password: String, + next: Option, +} + +#[derive(Debug, Deserialize)] +struct ConfirmForm { + code: String, + next: Option, +} + +fn safe_next(raw: Option<&str>, base: &str) -> String { + let Some(path) = crate::routes::local_next_path(raw) else { + return String::new(); + }; + super::strip_base_prefix(path, base).to_string() +} + +fn key_missing(state: &AppState, locale: Locale) -> Response { + ( + StatusCode::SERVICE_UNAVAILABLE, + state.locales.t(locale, "admin-mfa-error-key", None), + ) + .into_response() +} + +async fn status( + session: AdminSession, + State(state): State, + loc: Locale, + theme: Theme, + Query(query): Query, +) -> Response { + let next = safe_next(query.next.as_deref(), &state.base_path); + render_status(&state, session, loc, theme, "", next, StatusCode::OK).await +} + +async fn render_status( + state: &AppState, + session: AdminSession, + loc: Locale, + theme: Theme, + error: &'static str, + next: String, + status: StatusCode, +) -> Response { + let Some(db) = state.db.as_ref() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "no database — start with --db", + ) + .into_response(); + }; + let row = match session.actor.as_deref() { + Some(username) => match db::mfa::fetch(db, username).await { + Ok(row) => row, + Err(err) => { + tracing::error!(error = ?err, "fetch own MFA status failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response(); + } + }, + None => None, + }; + let enrolled_at = row + .as_ref() + .and_then(|row| row.confirmed_at) + .map(|at| at.format("%Y-%m-%d %H:%M UTC").to_string()) + .unwrap_or_default(); + let pending = row.is_some_and(|row| row.confirmed_at.is_none()); + let page = AccountMfaPage { + locale: loc, + theme, + locales: &state.locales, + locales_all: &Locale::ALL, + base: state.base_path.clone(), + nav_section: "account", + role: session.role, + enrolled_at, + pending, + break_glass: session.actor.is_none(), + error, + next, + }; + let body = match page.render() { + Ok(body) => body, + Err(err) => { + tracing::error!(error = ?err, "render MFA status failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response(); + } + }; + (status, axum::response::Html(body)).into_response() +} + +/// Cookie binding the pending enrollment ceremony to the browser that +/// passed the password re-auth (codex review, #1005). Without it, ANY +/// session for the same username — e.g. a stolen cookie that never +/// re-authenticated — could post a wrong code and be handed the decrypted +/// secret + QR via the retry re-render. Short-lived; scoped to the MFA +/// pages; cleared on success. +const CEREMONY_COOKIE: &str = "__ruscker_mfa_ceremony"; + +fn ceremony_cookie_path(base: &str) -> String { + format!("{base}/admin/account/mfa") +} + +fn issue_ceremony_cookie(state: &AppState, cookies: &Cookies, headers: &HeaderMap, value: String) { + let mut c = Cookie::new(CEREMONY_COOKIE, value); + c.set_path(ceremony_cookie_path(&state.base_path)); + c.set_http_only(true); + c.set_same_site(tower_cookies::cookie::SameSite::Strict); + c.set_secure(crate::auth::request_is_https( + headers, + crate::routes::proxy::forward_headers_trusted(&state.config.server), + )); + c.set_max_age(tower_cookies::cookie::time::Duration::minutes(15)); + cookies.add(c); +} + +fn clear_ceremony_cookie(state: &AppState, cookies: &Cookies) { + let mut c = Cookie::new(CEREMONY_COOKIE, ""); + // The removal path MUST match the issuing path — a Path-less removal + // never clears a scoped cookie (the #923 lesson). + c.set_path(ceremony_cookie_path(&state.base_path)); + cookies.remove(c); +} + +async fn start( + session: AdminSession, + State(state): State, + cookies: Cookies, + headers: HeaderMap, + loc: Locale, + theme: Theme, + Form(form): Form, +) -> Response { + let next = safe_next(form.next.as_deref(), &state.base_path); + let Some(username) = session.actor.clone() else { + return render_status( + &state, + session, + loc, + theme, + "break-glass", + next, + StatusCode::FORBIDDEN, + ) + .await; + }; + if !state.master_key.is_configured() { + return key_missing(&state, loc); + } + let Some(db) = state.db.as_ref() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "no database — start with --db", + ) + .into_response(); + }; + if !crate::mfa::REAUTH_LIMITER.try_reserve(&username) { + let mut response = render_status( + &state, + session, + loc, + theme, + "rate-limited", + next, + StatusCode::TOO_MANY_REQUESTS, + ) + .await; + response + .headers_mut() + .insert(RETRY_AFTER, "60".parse().unwrap()); + return response; + } + match db::users::verify_login(db, &username, &form.current_password).await { + Ok(Some(_)) => { + crate::mfa::REAUTH_LIMITER.record_success(&username); + } + Ok(None) => { + return render_status( + &state, + session, + loc, + theme, + "wrong-password", + next, + StatusCode::UNAUTHORIZED, + ) + .await; + } + Err(err) => { + tracing::error!(error = ?err, %username, "MFA re-authentication failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "login error").into_response(); + } + } + + match db::mfa::fetch(db, &username).await { + Ok(Some(row)) if row.confirmed_at.is_some() => { + return render_status( + &state, + session, + loc, + theme, + "already-enrolled", + next, + StatusCode::CONFLICT, + ) + .await; + } + Ok(_) => {} + Err(err) => { + tracing::error!(error = ?err, %username, "fetch MFA state before enrollment failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response(); + } + } + + let enrollment = match crate::mfa::begin(&username) { + Ok(enrollment) => enrollment, + Err(err) => { + tracing::error!(error = ?err, %username, "generate MFA enrollment failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "MFA setup error").into_response(); + } + }; + let (secret_enc, nonce) = match state + .master_key + .encrypt(enrollment.secret_base32.as_bytes()) + { + Ok(parts) => parts, + Err(err) => { + tracing::error!(error = ?err, %username, "encrypt MFA secret failed"); + return key_missing(&state, loc); + } + }; + let ceremony = uuid::Uuid::new_v4().to_string(); + if let Err(err) = + db::mfa::begin_enrollment(db, &username, &secret_enc, &nonce, &ceremony).await + { + tracing::warn!(error = ?err, %username, "persist pending MFA enrollment failed"); + return render_status( + &state, + session, + loc, + theme, + "already-enrolled", + next, + StatusCode::CONFLICT, + ) + .await; + } + // A password-reauthenticated restart is a new setup ceremony, so stale + // mistakes against the previous pending secret must not carry over. + crate::mfa::CONFIRM_LIMITER.record_success(&username); + issue_ceremony_cookie(&state, &cookies, &headers, ceremony); + render_setup( + &state, + session.role, + loc, + theme, + enrollment, + "", + next, + StatusCode::OK, + ) +} + +#[allow(clippy::too_many_arguments)] +fn render_setup( + state: &AppState, + role: Role, + loc: Locale, + theme: Theme, + enrollment: crate::mfa::Enrollment, + error: &'static str, + next: String, + status: StatusCode, +) -> Response { + let page = AccountMfaSetupPage { + locale: loc, + theme, + locales: &state.locales, + locales_all: &Locale::ALL, + base: state.base_path.clone(), + nav_section: "account", + role, + qr_svg: enrollment.qr_svg, + secret: enrollment.secret_base32, + error, + next, + }; + match page.render() { + Ok(body) => { + let mut response = (status, axum::response::Html(body)).into_response(); + response + .headers_mut() + .insert(CACHE_CONTROL, "no-store".parse().unwrap()); + response + } + Err(err) => { + tracing::error!(error = ?err, "render MFA setup failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response() + } + } +} + +async fn confirm( + session: AdminSession, + State(state): State, + cookies: Cookies, + loc: Locale, + theme: Theme, + Form(form): Form, +) -> Response { + let next = safe_next(form.next.as_deref(), &state.base_path); + let Some(username) = session.actor.as_deref() else { + return ( + StatusCode::FORBIDDEN, + "break-glass sessions cannot enroll MFA", + ) + .into_response(); + }; + if !state.master_key.is_configured() { + return key_missing(&state, loc); + } + let Some(db) = state.db.as_ref() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "no database — start with --db", + ) + .into_response(); + }; + let row = match db::mfa::fetch(db, username).await { + Ok(Some(row)) if row.confirmed_at.is_none() => row, + Ok(_) => return (StatusCode::CONFLICT, "no pending MFA enrollment").into_response(), + Err(err) => { + tracing::error!(error = ?err, %username, "fetch pending MFA enrollment failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response(); + } + }; + // Ceremony gate BEFORE any decrypt/re-render (codex review, #1005): + // only the browser that passed the password re-auth holds this cookie, + // so another session for the same user can never fish the secret out + // of the wrong-code retry path, and a raced re-start (new ceremony) + // invalidates in-flight confirms instead of confirming the wrong + // secret. Generic 409 — no secret material in the response. + let ceremony_ok = cookies + .get(CEREMONY_COOKIE) + .is_some_and(|c| { + // Constant-time compare: the token gates secret disclosure. + let got = c.value().as_bytes(); + let want = row.ceremony.as_bytes(); + got.len() == want.len() + && got + .iter() + .zip(want) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + == 0 + }); + if !ceremony_ok { + return ( + StatusCode::CONFLICT, + "this browser did not start the pending enrollment — restart it", + ) + .into_response(); + } + let plaintext = match state.master_key.decrypt(&row.secret_enc, &row.secret_nonce) { + Ok(plaintext) => plaintext, + Err(err) => { + tracing::error!(error = ?err, %username, "decrypt pending MFA secret failed"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + "MFA secret cannot be decrypted with RUSCKER_MASTER_KEY", + ) + .into_response(); + } + }; + let secret = match std::str::from_utf8(&plaintext) { + Ok(secret) => secret, + Err(err) => { + tracing::error!(error = ?err, %username, "pending MFA secret is not UTF-8"); + return (StatusCode::INTERNAL_SERVER_ERROR, "invalid MFA state").into_response(); + } + }; + let enrollment = match crate::mfa::render_enrollment(secret, username) { + Ok(enrollment) => enrollment, + Err(err) => { + tracing::error!(error = ?err, %username, "render pending MFA enrollment failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "invalid MFA state").into_response(); + } + }; + + if !crate::mfa::CONFIRM_LIMITER.try_reserve(username) { + let mut response = render_setup( + &state, + session.role, + loc, + theme, + enrollment, + "rate-limited", + next, + StatusCode::TOO_MANY_REQUESTS, + ); + response + .headers_mut() + .insert(RETRY_AFTER, "60".parse().unwrap()); + return response; + } + let valid = match crate::mfa::verify_totp(secret, username, form.code.trim()) { + Ok(valid) => valid, + Err(err) => { + tracing::error!(error = ?err, %username, "verify enrollment TOTP failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "MFA verification error").into_response(); + } + }; + if !valid { + return render_setup( + &state, + session.role, + loc, + theme, + enrollment, + "wrong-code", + next, + StatusCode::UNAUTHORIZED, + ); + } + + let codes = match crate::mfa::generate_recovery_codes() { + Ok(codes) => codes, + Err(err) => { + tracing::error!(error = ?err, %username, "generate recovery codes failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "recovery-code error").into_response(); + } + }; + let mut hashes = Vec::with_capacity(codes.len()); + for code in &codes { + match crate::mfa::hash_recovery_code(code) { + Ok(hash) => hashes.push(hash), + Err(err) => { + tracing::error!(error = ?err, %username, "hash recovery code failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "recovery-code error").into_response(); + } + } + } + if let Err(err) = db::mfa::confirm_with_recovery_codes( + db, + username, + username, + Some(&hashes), + &row.ceremony, + ) + .await + { + tracing::warn!(error = ?err, %username, "confirm MFA enrollment failed"); + return (StatusCode::CONFLICT, "MFA enrollment was already confirmed").into_response(); + } + crate::mfa::CONFIRM_LIMITER.record_success(username); + clear_ceremony_cookie(&state, &cookies); + let page = AccountMfaRecoveryPage { + locale: loc, + theme, + locales: &state.locales, + locales_all: &Locale::ALL, + base: state.base_path.clone(), + nav_section: "account", + role: session.role, + codes, + next, + }; + let mut response = super::render(&page); + response + .headers_mut() + .insert(CACHE_CONTROL, "no-store".parse().unwrap()); + response +} diff --git a/crates/ruscker-admin/src/routes/admin/users.rs b/crates/ruscker-admin/src/routes/admin/users.rs index 8dfab578..b123f8cb 100644 --- a/crates/ruscker-admin/src/routes/admin/users.rs +++ b/crates/ruscker-admin/src/routes/admin/users.rs @@ -32,6 +32,7 @@ pub fn routes() -> Router { .route("/admin/users/{username}/groups", post(set_groups)) .route("/admin/users/{username}/profile", post(set_profile)) .route("/admin/users/{username}/password", post(reset_password)) + .route("/admin/users/{username}/mfa/reset", post(reset_mfa)) .route("/admin/users/{username}/delete", post(delete)) } @@ -104,7 +105,9 @@ struct UserEditPage<'a> { role: Role, user: db::users::UserRow, me: String, - /// "" | "saved" | "last-admin" | "bad-input" | "weak-password" + /// Empty means pending/not configured; otherwise the confirmation date. + mfa_enrolled_at: String, + /// "" | "saved" | "mfa-reset" | "last-admin" | "bad-input" | "weak-password" flash: &'static str, } @@ -326,11 +329,22 @@ async fn edit( }; let flash = match q.flash.as_deref() { Some("saved") => "saved", + Some("mfa-reset") => "mfa-reset", Some("last-admin") => "last-admin", Some("bad-input") => "bad-input", Some("weak-password") => "weak-password", _ => "", }; + let mfa_enrolled_at = match db::mfa::fetch(pool, &username).await { + Ok(row) => row + .and_then(|row| row.confirmed_at) + .map(|at| at.format("%Y-%m-%d %H:%M UTC").to_string()) + .unwrap_or_default(), + Err(e) => { + tracing::error!(error = ?e, %username, "fetch MFA status for user edit failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response(); + } + }; super::render(&UserEditPage { locale: loc, theme, @@ -341,6 +355,7 @@ async fn edit( role: admin.role, user, me: admin.actor().to_string(), + mfa_enrolled_at, flash, }) } @@ -550,6 +565,35 @@ async fn reset_password( } } +async fn reset_mfa( + admin: RequireAdmin, + State(state): State, + Path(username): Path, +) -> Response { + let Some(pool) = state.db.as_ref() else { + return (StatusCode::SERVICE_UNAVAILABLE, "no db").into_response(); + }; + let username = db::users::normalize_username(&username); + if !db::users::is_valid_username(&username) { + return StatusCode::NOT_FOUND.into_response(); + } + match db::users::fetch(pool, &username).await { + Ok(Some(_)) => {} + Ok(None) => return StatusCode::NOT_FOUND.into_response(), + Err(e) => { + tracing::error!(error = ?e, %username, "fetch user before MFA reset failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response(); + } + } + match db::mfa::reset(pool, &username, admin.actor()).await { + Ok(()) => redirect_edit_flash(&username, "mfa-reset"), + Err(e) => { + tracing::warn!(error = ?e, %username, "reset user MFA failed"); + redirect_edit_flash(&username, "bad-input") + } + } +} + async fn delete( admin: RequireAdmin, State(state): State, diff --git a/crates/ruscker-admin/templates/_chrome_cluster.html b/crates/ruscker-admin/templates/_chrome_cluster.html index 2bd96a3f..60a3065c 100644 --- a/crates/ruscker-admin/templates/_chrome_cluster.html +++ b/crates/ruscker-admin/templates/_chrome_cluster.html @@ -79,6 +79,9 @@ {{ self.t("chrome-change-password") }} + + {{ self.t("chrome-mfa") }} +
+
+ + {% endif %} + +{% endblock %} diff --git a/crates/ruscker-admin/templates/admin/account_mfa_recovery.html b/crates/ruscker-admin/templates/admin/account_mfa_recovery.html new file mode 100644 index 00000000..9c869ef5 --- /dev/null +++ b/crates/ruscker-admin/templates/admin/account_mfa_recovery.html @@ -0,0 +1,21 @@ +{% extends "admin/_layout.html" %} + +{% block title %}{{ self.t("admin-mfa-recovery-title") }} · Ruscker{% endblock %} + +{% block content %} +
+

{{ self.t("admin-mfa-recovery-title") }}

+ +

{{ self.t("admin-mfa-recovery-help") }}

+
    + {% for code in codes %}
  1. {{ code }}
  2. {% endfor %} +
+ +
+{% endblock %} diff --git a/crates/ruscker-admin/templates/admin/account_mfa_setup.html b/crates/ruscker-admin/templates/admin/account_mfa_setup.html new file mode 100644 index 00000000..88ac2785 --- /dev/null +++ b/crates/ruscker-admin/templates/admin/account_mfa_setup.html @@ -0,0 +1,40 @@ +{% extends "admin/_layout.html" %} + +{% block title %}{{ self.t("admin-mfa-setup-title") }} · Ruscker{% endblock %} + +{% block content %} +
+

{{ self.t("admin-mfa-setup-title") }}

+

{{ self.t("admin-mfa-setup-help") }}

+ + {% if error != "" %} + + {% endif %} + +
+
{{ qr_svg|safe }}
+
+
{{ self.t("admin-mfa-manual-title") }}
+

{{ self.t("admin-mfa-manual-help") }}

+ {{ secret }} +

{{ self.t("admin-mfa-profile") }}

+
+
+ +
+ {% if next != "" %}{% endif %} + + +
+
+{% endblock %} diff --git a/crates/ruscker-admin/templates/admin/user_edit.html b/crates/ruscker-admin/templates/admin/user_edit.html index d8dcda35..5e78b82c 100644 --- a/crates/ruscker-admin/templates/admin/user_edit.html +++ b/crates/ruscker-admin/templates/admin/user_edit.html @@ -21,10 +21,10 @@

{{ self.t("admin-users-edit-title {% else if flash == "weak-password" %}{{ self.t("admin-users-flash-weak-password") }} {% else %}{{ self.t("admin-users-flash-bad-input") }}{% endif %} -{% else if flash == "saved" %} +{% else if flash == "saved" || flash == "mfa-reset" %}
- {{ self.t("admin-users-flash-saved") }} + {% if flash == "mfa-reset" %}{{ self.t("admin-users-mfa-reset-ok") }}{% else %}{{ self.t("admin-users-flash-saved") }}{% endif %}
{% endif %} @@ -84,6 +84,23 @@

{{ self.t("admin-users-edit-title +
+
{{ self.t("admin-users-mfa-section") }}
+ {% if mfa_enrolled_at != "" %} +

{{ self.t("admin-users-mfa-configured") }} {{ mfa_enrolled_at }}

+

{{ self.t("admin-users-mfa-reset-hint") }}

+
+ +
+ {% else %} +

{{ self.t("admin-users-mfa-not-configured") }}

+ {% endif %} +
+
{{ self.t("admin-users-password-section") }}

{{ self.t("admin-users-password-reset-hint") }}

diff --git a/crates/ruscker-admin/tests/mfa_enrollment.rs b/crates/ruscker-admin/tests/mfa_enrollment.rs new file mode 100644 index 00000000..4faa2d57 --- /dev/null +++ b/crates/ruscker-admin/tests/mfa_enrollment.rs @@ -0,0 +1,578 @@ +//! TOTP enrollment and admin-reset integration coverage (#1005 slice 2). + +use axum::body::{to_bytes, Body}; +use axum::http::{Request, StatusCode}; +use ruscker_admin::auth::{AdminAuth, Role, COOKIE_NAME}; +use ruscker_admin::{router, AppState}; +use ruscker_config::Config; +use std::sync::Arc; +use tower::ServiceExt; + +const YAML: &str = "proxy:\n title: Test\n specs: []\n"; +const PASSWORD: &str = "CorrectPass9!"; + +async fn state_with_db(master_key: bool) -> (AppState, ruscker_admin::db::ConfigDb) { + std::env::set_var("DOCKER_REGISTRY_PASSWORD", "test"); + let path = std::env::temp_dir().join(format!("ruscker-mfa-{}.db", uuid::Uuid::new_v4())); + let pool = ruscker_admin::db::open(&path).await.expect("open db"); + let db = ruscker_admin::db::ConfigDb::Sqlite(pool); + let state = AppState { + config: Arc::new(Config::from_yaml(YAML).expect("parse config")), + base_path: Arc::from(""), + locales: Arc::new(ruscker_admin::i18n::Locales::load().expect("load locales")), + admin_auth: AdminAuth::with_token("break-glass-tok"), + admin_sessions: Arc::new(ruscker_admin::auth::InMemoryAdminSessionStore::default()), + log_buffer: None, + login_limiter: Arc::new(ruscker_admin::auth::LoginRateLimiter::default_policy()), + api_limiter: Arc::new(ruscker_admin::ratelimit::ApiRateLimiter::new()), + db: Some(db.clone()), + images_dir: None, + master_key: if master_key { + ruscker_admin::crypto::MasterKey::parse(&"ab".repeat(32)).unwrap() + } else { + Default::default() + }, + backend: None, + replicas: Arc::new(tokio::sync::RwLock::new(Default::default())), + cookie_key: ruscker_proxy::sticky::CookieKey::random(), + spawn_locks: Arc::new(dashmap::DashMap::new()), + sessions: Arc::new(ruscker_admin::sessions::InMemorySessionStore::new()), + logout_index: Arc::new(dashmap::DashMap::new()), + leader: Arc::new(ruscker_admin::leader::AlwaysLeader), + metrics: ruscker_admin::metrics_cache::MetricsCache::new(), + draining: Arc::new(std::sync::atomic::AtomicBool::new(false)), + spec_cache: Arc::new(dashmap::DashMap::new()), + identity_cache: Default::default(), + catalog_cache: Arc::new(tokio::sync::RwLock::new(None)), + access_counter: Arc::new(ruscker_admin::access_counter::AccessCounter::default()), + alerts: ruscker_admin::alerts::AlertSink::default(), + }; + (state, db) +} + +async fn create_user(db: &ruscker_admin::db::ConfigDb, username: &str, must_change: bool) { + ruscker_admin::db::users::create( + db, + username, + PASSWORD, + Role::Viewer, + must_change, + &[], + Some("test"), + ) + .await + .unwrap(); +} + +async fn cookie(state: &AppState, role: Role, actor: Option) -> String { + let id = state.admin_sessions.create(role, actor).await; + format!("{COOKIE_NAME}={id}") +} + +async fn request( + state: AppState, + method: &str, + uri: &str, + body: &str, + cookie: &str, +) -> (StatusCode, String, Option) { + let (status, body, location, _) = request_full(state, method, uri, body, cookie).await; + (status, body, location) +} + +/// Like [`request`], also returning the response's `Set-Cookie` values so a +/// test can carry the enrollment-ceremony cookie from start to confirm the +/// way a real browser would (#1005 ceremony binding). +async fn request_full( + state: AppState, + method: &str, + uri: &str, + body: &str, + cookie: &str, +) -> (StatusCode, String, Option, Vec) { + let response = router(state) + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header("cookie", cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let location = response + .headers() + .get("location") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let set_cookies: Vec = response + .headers() + .get_all("set-cookie") + .iter() + .filter_map(|value| value.to_str().ok()) + .map(str::to_string) + .collect(); + let body = to_bytes(response.into_body(), 2 << 20).await.unwrap(); + ( + status, + String::from_utf8(body.to_vec()).unwrap(), + location, + set_cookies, + ) +} + +/// The `name=value` pair of the ceremony cookie from a start response. +fn ceremony_pair(set_cookies: &[String]) -> String { + set_cookies + .iter() + .find(|c| c.starts_with("__ruscker_mfa_ceremony=")) + .and_then(|c| c.split(';').next()) + .expect("start must set the ceremony cookie") + .to_string() +} + +async fn start(state: AppState, cookie: &str) -> (StatusCode, String) { + let (status, body, _) = start_with_ceremony(state, cookie).await; + (status, body) +} + +/// Start enrollment and return the session+ceremony cookie header a real +/// browser would send on the following confirm. +async fn start_with_ceremony(state: AppState, cookie: &str) -> (StatusCode, String, String) { + let (status, body, _, set_cookies) = request_full( + state, + "POST", + "/admin/account/mfa/start", + "current_password=CorrectPass9%21&next=%2Fapp%2Fdemo%2F", + cookie, + ) + .await; + let combined = if status == StatusCode::OK { + format!("{cookie}; {}", ceremony_pair(&set_cookies)) + } else { + cookie.to_string() + }; + (status, body, combined) +} + +fn decrypted_secret(state: &AppState, row: &ruscker_admin::db::mfa::MfaRow) -> String { + let plaintext = state + .master_key + .decrypt(&row.secret_enc, &row.secret_nonce) + .unwrap(); + String::from_utf8(plaintext.to_vec()).unwrap() +} + +#[tokio::test] +async fn full_enrollment_persists_encrypted_pending_then_displays_codes_once() { + let (state, db) = state_with_db(true).await; + create_user(&db, "alice", false).await; + let user_cookie = cookie(&state, Role::Viewer, Some("alice".into())).await; + + let (start_status, setup, browser_cookie) = + start_with_ceremony(state.clone(), &user_cookie).await; + assert_eq!(start_status, StatusCode::OK); + assert!(setup.contains("data-mfa-qr")); + assert!(setup.contains("data-mfa-secret")); + + let pending = ruscker_admin::db::mfa::fetch(&db, "alice") + .await + .unwrap() + .unwrap(); + assert!(pending.confirmed_at.is_none()); + let secret = decrypted_secret(&state, &pending); + assert_ne!(pending.secret_enc.as_slice(), secret.as_bytes()); + assert_eq!(pending.secret_nonce.len(), 12); + let code = ruscker_admin::mfa::totp(&secret, "alice") + .unwrap() + .generate_current() + .unwrap(); + + let (confirm_status, recovery, _) = request( + state.clone(), + "POST", + "/admin/account/mfa/confirm", + &format!("code={code}&next=%2Fapp%2Fdemo%2F"), + &browser_cookie, + ) + .await; + assert_eq!(confirm_status, StatusCode::OK); + assert!(recovery.contains("data-recovery-codes")); + assert!(recovery.contains("/app/demo/")); + let row = ruscker_admin::db::mfa::fetch(&db, "alice") + .await + .unwrap() + .unwrap(); + assert!(row.confirmed_at.is_some()); + let ruscker_admin::db::ConfigDb::Sqlite(pool) = &db else { + unreachable!() + }; + let (count,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM user_mfa_recovery WHERE username = ? AND used_at IS NULL", + ) + .bind("alice") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(count, 10); + let (enroll_audit,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM audit_log + WHERE action = 'mfa.enroll' AND actor = 'alice' AND target = 'user:alice'", + ) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(enroll_audit, 1); + + let (status, status_page, _) = request( + state, + "GET", + "/admin/account/mfa?next=%2Fapp%2Fdemo%2F", + "", + &user_cookie, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(status_page.contains("data-mfa-state=\"enrolled\"")); + assert!(status_page.contains("href=\"/app/demo/\"")); + assert!(!status_page.contains("data-recovery-codes")); + assert!(!status_page.contains(&secret)); +} + +#[tokio::test] +async fn wrong_password_is_rejected_and_wrong_code_preserves_pending_for_retry() { + let (state, db) = state_with_db(true).await; + let username = format!("retry-{}", uuid::Uuid::new_v4().simple()); + create_user(&db, &username, false).await; + let user_cookie = cookie(&state, Role::Viewer, Some(username.clone())).await; + let (bad_password, _, _) = request( + state.clone(), + "POST", + "/admin/account/mfa/start", + "current_password=wrong", + &user_cookie, + ) + .await; + assert_eq!(bad_password, StatusCode::UNAUTHORIZED); + assert!(ruscker_admin::db::mfa::fetch(&db, &username) + .await + .unwrap() + .is_none()); + + let (start_status, _, browser_cookie) = + start_with_ceremony(state.clone(), &user_cookie).await; + assert_eq!(start_status, StatusCode::OK); + let pending = ruscker_admin::db::mfa::fetch(&db, &username) + .await + .unwrap() + .unwrap(); + let secret = decrypted_secret(&state, &pending); + let (wrong, setup, _) = request( + state.clone(), + "POST", + "/admin/account/mfa/confirm", + "code=garbage", + &browser_cookie, + ) + .await; + assert_eq!(wrong, StatusCode::UNAUTHORIZED); + assert!(setup.contains(&secret)); + assert!(ruscker_admin::db::mfa::fetch(&db, &username) + .await + .unwrap() + .unwrap() + .confirmed_at + .is_none()); + + let code = ruscker_admin::mfa::totp(&secret, &username) + .unwrap() + .generate_current() + .unwrap(); + let (retry, _, _) = request( + state, + "POST", + "/admin/account/mfa/confirm", + &format!("code={code}"), + &browser_cookie, + ) + .await; + assert_eq!(retry, StatusCode::OK); +} + +#[tokio::test] +async fn confirm_rate_limit_trips_after_five_wrong_codes() { + let (state, db) = state_with_db(true).await; + let username = format!("limited-{}", uuid::Uuid::new_v4().simple()); + create_user(&db, &username, false).await; + let user_cookie = cookie(&state, Role::Viewer, Some(username)).await; + let (start_status, _, browser_cookie) = + start_with_ceremony(state.clone(), &user_cookie).await; + assert_eq!(start_status, StatusCode::OK); + for _ in 0..5 { + let (status, _, _) = request( + state.clone(), + "POST", + "/admin/account/mfa/confirm", + "code=garbage", + &browser_cookie, + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + let (status, body, _) = request( + state, + "POST", + "/admin/account/mfa/confirm", + "code=garbage", + &browser_cookie, + ) + .await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert!(body.contains("data-mfa-error=\"rate-limited\"")); +} + +#[tokio::test] +async fn enrollment_fails_closed_without_master_key() { + let (state, db) = state_with_db(false).await; + create_user(&db, "nokey", false).await; + let user_cookie = cookie(&state, Role::Viewer, Some("nokey".into())).await; + let (status, body) = start(state, &user_cookie).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert!(body.contains("RUSCKER_MASTER_KEY")); + assert!(ruscker_admin::db::mfa::fetch(&db, "nokey") + .await + .unwrap() + .is_none()); +} + +#[tokio::test] +async fn admin_reset_removes_factor_and_codes_audits_and_allows_reenrollment() { + let (state, db) = state_with_db(true).await; + create_user(&db, "carol", false).await; + create_user(&db, "root", false).await; + let user_cookie = cookie(&state, Role::Viewer, Some("carol".into())).await; + let (start_status, _, browser_cookie) = + start_with_ceremony(state.clone(), &user_cookie).await; + assert_eq!(start_status, StatusCode::OK); + let pending = ruscker_admin::db::mfa::fetch(&db, "carol") + .await + .unwrap() + .unwrap(); + let secret = decrypted_secret(&state, &pending); + let code = ruscker_admin::mfa::totp(&secret, "carol") + .unwrap() + .generate_current() + .unwrap(); + assert_eq!( + request( + state.clone(), + "POST", + "/admin/account/mfa/confirm", + &format!("code={code}"), + &browser_cookie, + ) + .await + .0, + StatusCode::OK + ); + + let admin_cookie = cookie(&state, Role::Admin, Some("root".into())).await; + let (edit_status, edit_body, _) = request( + state.clone(), + "GET", + "/admin/users/carol/edit", + "", + &admin_cookie, + ) + .await; + assert_eq!(edit_status, StatusCode::OK); + assert!(edit_body.contains("data-mfa-admin-state=\"configured\"")); + let (reset, _, location) = request( + state.clone(), + "POST", + "/admin/users/carol/mfa/reset", + "", + &admin_cookie, + ) + .await; + assert_eq!(reset, StatusCode::SEE_OTHER); + assert_eq!( + location.as_deref(), + Some("/admin/users/carol/edit?flash=mfa-reset") + ); + assert!(ruscker_admin::db::mfa::fetch(&db, "carol") + .await + .unwrap() + .is_none()); + let ruscker_admin::db::ConfigDb::Sqlite(pool) = &db else { + unreachable!() + }; + let (codes,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM user_mfa_recovery WHERE username = 'carol'") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(codes, 0); + let (audits,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM audit_log + WHERE action = 'mfa.reset' AND actor = 'root' AND target = 'user:carol'", + ) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(audits, 1); + assert_eq!(start(state, &user_cookie).await.0, StatusCode::OK); +} + +#[tokio::test] +async fn break_glass_cannot_enroll_and_must_change_user_is_pinned_to_password() { + let (state, db) = state_with_db(true).await; + create_user(&db, "firstlogin", true).await; + let token_cookie = cookie(&state, Role::Admin, None).await; + let (break_glass, body) = start(state.clone(), &token_cookie).await; + assert_eq!(break_glass, StatusCode::FORBIDDEN); + assert!(body.contains("data-mfa-error=\"break-glass\"")); + + let first_cookie = cookie(&state, Role::Viewer, Some("firstlogin".into())).await; + let (status, _, location) = + request(state, "GET", "/admin/account/mfa", "", &first_cookie).await; + assert_eq!(status, StatusCode::SEE_OTHER); + assert_eq!(location.as_deref(), Some("/admin/account/password")); +} + + +/// P1 from the codex review (#1005): a second session for the SAME user — +/// e.g. a stolen session cookie that never passed the password re-auth — +/// must not be able to fish the pending secret out of the wrong-code retry +/// re-render, nor confirm the enrollment. +#[tokio::test] +async fn another_session_without_ceremony_cookie_never_sees_the_secret() { + let (state, db) = state_with_db(true).await; + create_user(&db, "dana", false).await; + let enrolling = cookie(&state, Role::Viewer, Some("dana".into())).await; + let (start_status, _, _browser) = start_with_ceremony(state.clone(), &enrolling).await; + assert_eq!(start_status, StatusCode::OK); + let secret = decrypted_secret( + &state, + &ruscker_admin::db::mfa::fetch(&db, "dana").await.unwrap().unwrap(), + ); + + // A different session for the same user, WITHOUT the ceremony cookie. + let hijacker = cookie(&state, Role::Viewer, Some("dana".into())).await; + let (status, body, _) = request( + state.clone(), + "POST", + "/admin/account/mfa/confirm", + "code=000000", + &hijacker, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert!( + !body.contains(&secret), + "the retry path must never disclose the secret to a session without the ceremony" + ); + // And a correct code without the ceremony cannot confirm either. + let code = ruscker_admin::mfa::totp(&secret, "dana") + .unwrap() + .generate_current() + .unwrap(); + let (status, _, _) = request( + state.clone(), + "POST", + "/admin/account/mfa/confirm", + &format!("code={code}"), + &hijacker, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert!(ruscker_admin::db::mfa::fetch(&db, "dana") + .await + .unwrap() + .unwrap() + .confirmed_at + .is_none()); +} + +/// P2 from the codex review (#1005): a re-started enrollment replaces the +/// pending secret AND the ceremony, so a confirm still carrying the OLD +/// ceremony must fail — never confirm secret B with a proof of secret A. +#[tokio::test] +async fn stale_ceremony_after_restart_cannot_confirm() { + let (state, db) = state_with_db(true).await; + create_user(&db, "erik", false).await; + let user_cookie = cookie(&state, Role::Viewer, Some("erik".into())).await; + + let (s1, _, old_browser) = start_with_ceremony(state.clone(), &user_cookie).await; + assert_eq!(s1, StatusCode::OK); + let old_secret = decrypted_secret( + &state, + &ruscker_admin::db::mfa::fetch(&db, "erik").await.unwrap().unwrap(), + ); + + // Re-start: new secret + new ceremony replace the pending row. + let (s2, _, _new_browser) = start_with_ceremony(state.clone(), &user_cookie).await; + assert_eq!(s2, StatusCode::OK); + + // The old browser proves the OLD secret with its OLD ceremony: rejected. + let code = ruscker_admin::mfa::totp(&old_secret, "erik") + .unwrap() + .generate_current() + .unwrap(); + let (status, _, _) = request( + state.clone(), + "POST", + "/admin/account/mfa/confirm", + &format!("code={code}"), + &old_browser, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert!(ruscker_admin::db::mfa::fetch(&db, "erik") + .await + .unwrap() + .unwrap() + .confirmed_at + .is_none()); +} + + +/// Codex review round 2 (#1005): a stolen session cookie must not turn +/// /start into an unlimited password oracle — five wrong passwords rate- +/// limit the account, and even the CORRECT password is then refused until +/// the window passes. +#[tokio::test] +async fn start_password_reauth_is_rate_limited() { + let (state, db) = state_with_db(true).await; + let username = format!("oracle-{}", uuid::Uuid::new_v4().simple()); + create_user(&db, &username, false).await; + let user_cookie = cookie(&state, Role::Viewer, Some(username.clone())).await; + for _ in 0..5 { + let (status, _, _) = request( + state.clone(), + "POST", + "/admin/account/mfa/start", + "current_password=wrong-guess", + &user_cookie, + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + let (status, body, _) = request( + state.clone(), + "POST", + "/admin/account/mfa/start", + "current_password=CorrectPass9%21", + &user_cookie, + ) + .await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert!(body.contains("data-mfa-error=\"rate-limited\"")); + assert!(ruscker_admin::db::mfa::fetch(&db, &username) + .await + .unwrap() + .is_none()); +}