From c47e188e4480e1c33ee52c1e6329d3cb315f2c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Leite?= Date: Fri, 17 Jul 2026 21:11:23 -0300 Subject: [PATCH 01/12] feat: MFA challenge + trusted-device grants (#1005 slice 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proof machinery the slice-4 proxy guard will consume. No proxy changes yet. - Migration 0030 (dual-dialect): user_mfa_grants — opaque 32-byte token stored only as salt:SHA-256, bound to user + mfa_verified_at + expiry (30-day cap) + the factor it proved (confirmed_at binding: a reset/re-enrollment invalidates old grants) + a session-id hash for the 0-day semantics. - Challenge at /admin/account/mfa/challenge: TOTP or recovery code → device grant + cookie (__ruscker_mfa_device={id}.{token}, HttpOnly, SameSite=Strict, base-path root so slice 4 sees it on /app/*). Audit mfa.verify / mfa.recovery_used. Atomic try_reserve limiter. - Decision API mfa::evaluate → Satisfied | ChallengeRequired | EnrollmentRequired: policy → enrollment → cookie → grant → constant- time token compare → expiry → factor binding → 0-day session binding or proof-age vs the app's window. One proof serves all apps, each comparing the age against its own effective_mfa_validity_days(). - TOTP replay prevention: the accepted step (current/±1, derived explicitly) is recorded via a conditional last_used_step update — covers challenge AND enrollment confirm. - Revocations: password set/change and MFA reset delete grants inside their existing transactions; user deletion cascades; explicit "forget this device" / "sign out all devices" buttons audit mfa.trusted_device.revoke. Normal logout keeps the device. Implemented by a Codex (gpt-5.6, high) agent. Verified: full gate green; postgres-it green vs real postgres:16 (grants round-trip); live smoke — challenge with a python-computed step+1 code (the enrollment consumed the current step; replay guard forces strictly-greater), grant row hash-only, same-code replay 401, password change revoked the grant, audit trail correct. Co-Authored-By: Claude Fable 5 --- .../ruscker-admin/assets/i18n/en/landing.ftl | 14 + .../ruscker-admin/assets/i18n/es/landing.ftl | 14 + .../ruscker-admin/assets/i18n/fr/landing.ftl | 14 + .../ruscker-admin/assets/i18n/pt/landing.ftl | 14 + .../migrations-pg/0030_user_mfa_grants.sql | 14 + .../migrations/0030_user_mfa_grants.sql | 16 + crates/ruscker-admin/src/db.rs | 1 + crates/ruscker-admin/src/db/mfa.rs | 68 ++ crates/ruscker-admin/src/db/mfa_grants.rs | 300 +++++++++ crates/ruscker-admin/src/db/users.rs | 5 + crates/ruscker-admin/src/mfa.rs | 172 ++++- crates/ruscker-admin/src/routes/admin/mfa.rs | 449 ++++++++++++- .../templates/admin/account_mfa.html | 16 + .../admin/account_mfa_challenge.html | 49 ++ crates/ruscker-admin/tests/mfa_challenge.rs | 619 ++++++++++++++++++ 15 files changed, 1753 insertions(+), 12 deletions(-) create mode 100644 crates/ruscker-admin/migrations-pg/0030_user_mfa_grants.sql create mode 100644 crates/ruscker-admin/migrations/0030_user_mfa_grants.sql create mode 100644 crates/ruscker-admin/src/db/mfa_grants.rs create mode 100644 crates/ruscker-admin/templates/admin/account_mfa_challenge.html create mode 100644 crates/ruscker-admin/tests/mfa_challenge.rs diff --git a/crates/ruscker-admin/assets/i18n/en/landing.ftl b/crates/ruscker-admin/assets/i18n/en/landing.ftl index 027e484c..2c99460d 100644 --- a/crates/ruscker-admin/assets/i18n/en/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/en/landing.ftl @@ -1022,6 +1022,20 @@ 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-mfa-challenge-title = Verify two-factor authentication +admin-mfa-challenge-help = Enter a code to trust this browser for protected apps. +admin-mfa-challenge-break-glass = Break-glass token sessions have no user-owned factor. Protected-app bypass is handled separately by policy. +admin-mfa-challenge-method = Verification method +admin-mfa-challenge-totp = Authenticator code +admin-mfa-challenge-recovery = Recovery code +admin-mfa-challenge-code = Code +admin-mfa-challenge-submit = Verify and continue +admin-mfa-challenge-error = Incorrect or already-used code. Try again. +admin-mfa-challenge-replayed = This authenticator code was already used. Wait for the next code. +admin-mfa-forget-device = Forget this device +admin-mfa-forget-confirm = Forget the MFA proof stored for this browser? +admin-mfa-revoke-all = Sign out of all devices +admin-mfa-revoke-all-confirm = Revoke every trusted-device MFA proof for your account? 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. diff --git a/crates/ruscker-admin/assets/i18n/es/landing.ftl b/crates/ruscker-admin/assets/i18n/es/landing.ftl index a9ee16d2..d4924999 100644 --- a/crates/ruscker-admin/assets/i18n/es/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/es/landing.ftl @@ -1022,6 +1022,20 @@ 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-mfa-challenge-title = Verificar la autenticación de dos factores +admin-mfa-challenge-help = Introduce un código para confiar en este navegador en las aplicaciones protegidas. +admin-mfa-challenge-break-glass = Las sesiones de emergencia por token no tienen un factor del usuario. El acceso excepcional a aplicaciones protegidas se gestiona por separado mediante la política. +admin-mfa-challenge-method = Método de verificación +admin-mfa-challenge-totp = Código del autenticador +admin-mfa-challenge-recovery = Código de recuperación +admin-mfa-challenge-code = Código +admin-mfa-challenge-submit = Verificar y continuar +admin-mfa-challenge-error = Código incorrecto o ya utilizado. Inténtalo de nuevo. +admin-mfa-challenge-replayed = Este código del autenticador ya se utilizó. Espera al siguiente código. +admin-mfa-forget-device = Olvidar este dispositivo +admin-mfa-forget-confirm = ¿Olvidar la prueba de MFA guardada para este navegador? +admin-mfa-revoke-all = Cerrar sesión en todos los dispositivos +admin-mfa-revoke-all-confirm = ¿Revocar todas las pruebas MFA de dispositivos de confianza de tu cuenta? 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. diff --git a/crates/ruscker-admin/assets/i18n/fr/landing.ftl b/crates/ruscker-admin/assets/i18n/fr/landing.ftl index b1b9650c..f17477d6 100644 --- a/crates/ruscker-admin/assets/i18n/fr/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/fr/landing.ftl @@ -1022,6 +1022,20 @@ 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-mfa-challenge-title = Vérifier l’authentification à deux facteurs +admin-mfa-challenge-help = Saisissez un code pour approuver ce navigateur pour les applications protégées. +admin-mfa-challenge-break-glass = Les sessions d’urgence par jeton n’ont pas de facteur utilisateur. L’accès exceptionnel aux applications protégées est géré séparément par la politique. +admin-mfa-challenge-method = Méthode de vérification +admin-mfa-challenge-totp = Code d’authentification +admin-mfa-challenge-recovery = Code de récupération +admin-mfa-challenge-code = Code +admin-mfa-challenge-submit = Vérifier et continuer +admin-mfa-challenge-error = Code incorrect ou déjà utilisé. Réessayez. +admin-mfa-challenge-replayed = Ce code d’authentification a déjà été utilisé. Attendez le prochain code. +admin-mfa-forget-device = Oublier cet appareil +admin-mfa-forget-confirm = Oublier la preuve MFA enregistrée pour ce navigateur ? +admin-mfa-revoke-all = Déconnecter tous les appareils +admin-mfa-revoke-all-confirm = Révoquer toutes les preuves MFA des appareils approuvés de votre compte ? 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. diff --git a/crates/ruscker-admin/assets/i18n/pt/landing.ftl b/crates/ruscker-admin/assets/i18n/pt/landing.ftl index d62f0871..ffb929b1 100644 --- a/crates/ruscker-admin/assets/i18n/pt/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/pt/landing.ftl @@ -1026,6 +1026,20 @@ 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-mfa-challenge-title = Verificar autenticação em dois fatores +admin-mfa-challenge-help = Informe um código para confiar neste navegador nos aplicativos protegidos. +admin-mfa-challenge-break-glass = Sessões de emergência por token não têm um fator do usuário. O acesso excepcional a aplicativos protegidos é tratado separadamente pela política. +admin-mfa-challenge-method = Método de verificação +admin-mfa-challenge-totp = Código do autenticador +admin-mfa-challenge-recovery = Código de recuperação +admin-mfa-challenge-code = Código +admin-mfa-challenge-submit = Verificar e continuar +admin-mfa-challenge-error = Código incorreto ou já utilizado. Tente novamente. +admin-mfa-challenge-replayed = Este código do autenticador já foi utilizado. Aguarde o próximo código. +admin-mfa-forget-device = Esquecer este dispositivo +admin-mfa-forget-confirm = Esquecer a comprovação de MFA armazenada neste navegador? +admin-mfa-revoke-all = Sair de todos os dispositivos +admin-mfa-revoke-all-confirm = Revogar todas as comprovações de MFA de dispositivos confiáveis da sua conta? 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. diff --git a/crates/ruscker-admin/migrations-pg/0030_user_mfa_grants.sql b/crates/ruscker-admin/migrations-pg/0030_user_mfa_grants.sql new file mode 100644 index 00000000..c3666740 --- /dev/null +++ b/crates/ruscker-admin/migrations-pg/0030_user_mfa_grants.sql @@ -0,0 +1,14 @@ +-- Postgres twin of migrations/0030_user_mfa_grants.sql (#1005 slice 3). +CREATE TABLE user_mfa_grants ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + session_binding TEXT NOT NULL, + factor_confirmed_at TIMESTAMPTZ NOT NULL, + mfa_verified_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_user_mfa_grants_username + ON user_mfa_grants(username); diff --git a/crates/ruscker-admin/migrations/0030_user_mfa_grants.sql b/crates/ruscker-admin/migrations/0030_user_mfa_grants.sql new file mode 100644 index 00000000..03c5789b --- /dev/null +++ b/crates/ruscker-admin/migrations/0030_user_mfa_grants.sql @@ -0,0 +1,16 @@ +-- Device-bound MFA proofs (#1005 slice 3). The browser holds +-- `{id}.{token}`; only the salted token hash and a SHA-256 binding to the +-- admin-session id are persisted. Grants hard-expire after at most 30 days. +CREATE TABLE user_mfa_grants ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + session_binding TEXT NOT NULL, + factor_confirmed_at TEXT NOT NULL, + mfa_verified_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_user_mfa_grants_username + ON user_mfa_grants(username); diff --git a/crates/ruscker-admin/src/db.rs b/crates/ruscker-admin/src/db.rs index dc353d3c..828e2e99 100644 --- a/crates/ruscker-admin/src/db.rs +++ b/crates/ruscker-admin/src/db.rs @@ -165,6 +165,7 @@ pub mod images; pub mod landing; pub mod landing_blocks; pub mod mfa; +pub mod mfa_grants; pub mod ruscker_images; pub mod schedules; pub mod settings; diff --git a/crates/ruscker-admin/src/db/mfa.rs b/crates/ruscker-admin/src/db/mfa.rs index 7795dfbe..446bb151 100644 --- a/crates/ruscker-admin/src/db/mfa.rs +++ b/crates/ruscker-admin/src/db/mfa.rs @@ -440,6 +440,37 @@ pub async fn is_enrolled(db: &ConfigDb, username: &str) -> Result { Ok(exists) } +/// Atomically accept a TOTP time-step only when it is newer than the last +/// successful one for this enrollment. This closes replay races across both +/// challenge and enrollment-confirm requests, including active-active nodes. +pub async fn record_used_step(db: &ConfigDb, username: &str, step: i64) -> Result { + let username = crate::db::users::normalize_username(username); + let changed = match db { + ConfigDb::Sqlite(pool) => sqlx::query( + "UPDATE user_mfa SET last_used_step = ? + WHERE username = ? AND (last_used_step IS NULL OR last_used_step < ?)", + ) + .bind(step) + .bind(&username) + .bind(step) + .execute(pool) + .await + .with_context(|| format!("record MFA TOTP step for {username}"))? + .rows_affected(), + ConfigDb::Postgres(pool) => sqlx::query( + "UPDATE user_mfa SET last_used_step = $1 + WHERE username = $2 AND (last_used_step IS NULL OR last_used_step < $1)", + ) + .bind(step) + .bind(&username) + .execute(pool) + .await + .with_context(|| format!("record MFA TOTP step for {username}"))? + .rows_affected(), + }; + Ok(changed == 1) +} + /// 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<()> { @@ -449,6 +480,7 @@ pub async fn reset(db: &ConfigDb, username: &str, actor: &str) -> Result<()> { match db { ConfigDb::Sqlite(pool) => { let mut tx = pool.begin().await.context("begin MFA reset")?; + crate::db::mfa_grants::delete_all_sqlite(&mut tx, &username).await?; sqlx::query("DELETE FROM user_mfa_recovery WHERE username = ?") .bind(&username) .execute(&mut *tx) @@ -473,6 +505,7 @@ pub async fn reset(db: &ConfigDb, username: &str, actor: &str) -> Result<()> { } ConfigDb::Postgres(pool) => { let mut tx = pool.begin().await.context("begin MFA reset")?; + crate::db::mfa_grants::delete_all_postgres(&mut tx, &username).await?; sqlx::query("DELETE FROM user_mfa_recovery WHERE username = $1") .bind(&username) .execute(&mut *tx) @@ -600,6 +633,41 @@ mod tests { .is_none()); confirm_enrollment(&db, &username, &username, "cer-pg").await.unwrap(); assert!(is_enrolled(&db, &username).await.unwrap()); + let factor_confirmed_at = fetch(&db, &username) + .await + .unwrap() + .unwrap() + .confirmed_at + .unwrap(); + assert!(record_used_step(&db, &username, 42).await.unwrap()); + assert!(!record_used_step(&db, &username, 42).await.unwrap()); + let verified_at = Utc::now(); + let grant_id = crate::db::mfa_grants::create( + &db, + &username, + "salt:hash", + "session-binding", + factor_confirmed_at, + verified_at, + verified_at + chrono::Duration::days(30), + ) + .await + .unwrap(); + let grant = crate::db::mfa_grants::fetch_valid(&db, &grant_id) + .await + .unwrap() + .unwrap(); + assert_eq!(grant.username, username); + assert_eq!( + crate::db::mfa_grants::revoke_all(&db, &username, "root", "postgres-test") + .await + .unwrap(), + 1 + ); + assert!(crate::db::mfa_grants::fetch_valid(&db, &grant_id) + .await + .unwrap() + .is_none()); reset(&db, &username, "root").await.unwrap(); assert!(fetch(&db, &username).await.unwrap().is_none()); crate::db::users::delete(&db, &username, Some("test")) diff --git a/crates/ruscker-admin/src/db/mfa_grants.rs b/crates/ruscker-admin/src/db/mfa_grants.rs new file mode 100644 index 00000000..db3a810c --- /dev/null +++ b/crates/ruscker-admin/src/db/mfa_grants.rs @@ -0,0 +1,300 @@ +//! Server-side trusted-device grants for user-owned MFA proofs. +//! +//! The cookie's random token is never stored verbatim. `token_hash` carries +//! its own random salt (the same `salt:sha256` convention as recovery codes), +//! while `session_binding` is a one-way hash of the opaque admin-session id. + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; + +use crate::db::ConfigDb; + +#[derive(Debug, Clone)] +pub struct GrantRow { + pub id: String, + pub username: String, + pub token_hash: String, + pub session_binding: String, + pub factor_confirmed_at: DateTime, + pub mfa_verified_at: DateTime, + pub expires_at: DateTime, + pub created_at: DateTime, +} + +type StoredRow = ( + String, + String, + String, + String, + DateTime, + DateTime, + DateTime, + DateTime, +); + +pub async fn create( + db: &ConfigDb, + username: &str, + token_hash: &str, + session_binding: &str, + factor_confirmed_at: DateTime, + verified_at: DateTime, + expires_at: DateTime, +) -> Result { + let username = crate::db::users::normalize_username(username); + let id = uuid::Uuid::new_v4().to_string(); + match db { + ConfigDb::Sqlite(pool) => { + sqlx::query( + "INSERT INTO user_mfa_grants + (id, username, token_hash, session_binding, factor_confirmed_at, + mfa_verified_at, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(&username) + .bind(token_hash) + .bind(session_binding) + .bind(factor_confirmed_at) + .bind(verified_at) + .bind(expires_at) + .bind(verified_at) + .execute(pool) + .await + .with_context(|| format!("create MFA device grant for {username}"))?; + } + ConfigDb::Postgres(pool) => { + sqlx::query( + "INSERT INTO user_mfa_grants + (id, username, token_hash, session_binding, factor_confirmed_at, + mfa_verified_at, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + ) + .bind(&id) + .bind(&username) + .bind(token_hash) + .bind(session_binding) + .bind(factor_confirmed_at) + .bind(verified_at) + .bind(expires_at) + .bind(verified_at) + .execute(pool) + .await + .with_context(|| format!("create MFA device grant for {username}"))?; + } + } + Ok(id) +} + +/// Fetch one unexpired grant. Token, user, factor and session checks remain +/// in the decision layer so every mismatch follows the same fail-closed path. +pub async fn fetch_valid(db: &ConfigDb, id: &str) -> Result> { + let now = Utc::now(); + let row: Option = match db { + ConfigDb::Sqlite(pool) => { + sqlx::query_as( + "SELECT id, username, token_hash, session_binding, + factor_confirmed_at, mfa_verified_at, expires_at, created_at + FROM user_mfa_grants + WHERE id = ? AND expires_at > ?", + ) + .bind(id) + .bind(now) + .fetch_optional(pool) + .await + } + ConfigDb::Postgres(pool) => { + sqlx::query_as( + "SELECT id, username, token_hash, session_binding, + factor_confirmed_at, mfa_verified_at, expires_at, created_at + FROM user_mfa_grants + WHERE id = $1 AND expires_at > $2", + ) + .bind(id) + .bind(now) + .fetch_optional(pool) + .await + } + } + .context("fetch valid MFA device grant")?; + Ok(row.map( + |( + id, + username, + token_hash, + session_binding, + factor_confirmed_at, + mfa_verified_at, + expires_at, + created_at, + )| GrantRow { + id, + username, + token_hash, + session_binding, + factor_confirmed_at, + mfa_verified_at, + expires_at, + created_at, + }, + )) +} + +/// Revoke every trusted-device grant and audit only when something changed. +pub async fn revoke_all( + db: &ConfigDb, + username: &str, + actor: &str, + cause: &str, +) -> Result { + let username = crate::db::users::normalize_username(username); + let now = Utc::now(); + let target = format!("user:{username}"); + let diff = serde_json::json!({ "cause": cause }).to_string(); + let changed = match db { + ConfigDb::Sqlite(pool) => { + let mut tx = pool.begin().await.context("begin MFA grant revocation")?; + let changed = sqlx::query("DELETE FROM user_mfa_grants WHERE username = ?") + .bind(&username) + .execute(&mut *tx) + .await + .context("revoke all MFA device grants")? + .rows_affected(); + if changed > 0 { + sqlx::query( + "INSERT INTO audit_log (actor, action, target, diff_json, occurred_at) + VALUES (?, 'mfa.trusted_device.revoke', ?, ?, ?)", + ) + .bind(actor) + .bind(&target) + .bind(&diff) + .bind(now) + .execute(&mut *tx) + .await + .context("audit MFA device revocation")?; + } + tx.commit().await.context("commit MFA grant revocation")?; + changed + } + ConfigDb::Postgres(pool) => { + let mut tx = pool.begin().await.context("begin MFA grant revocation")?; + let changed = sqlx::query("DELETE FROM user_mfa_grants WHERE username = $1") + .bind(&username) + .execute(&mut *tx) + .await + .context("revoke all MFA device grants")? + .rows_affected(); + if changed > 0 { + sqlx::query( + "INSERT INTO audit_log (actor, action, target, diff_json, occurred_at) + VALUES ($1, 'mfa.trusted_device.revoke', $2, $3, $4)", + ) + .bind(actor) + .bind(&target) + .bind(&diff) + .bind(now) + .execute(&mut *tx) + .await + .context("audit MFA device revocation")?; + } + tx.commit().await.context("commit MFA grant revocation")?; + changed + } + }; + Ok(changed) +} + +/// Revoke one grant belonging to `username` and audit the explicit action. +pub async fn revoke_one(db: &ConfigDb, id: &str, username: &str, actor: &str) -> Result { + let username = crate::db::users::normalize_username(username); + let now = Utc::now(); + let target = format!("user:{username}"); + let diff = serde_json::json!({ "cause": "forget-device", "grant_id": id }).to_string(); + let changed = match db { + ConfigDb::Sqlite(pool) => { + let mut tx = pool.begin().await.context("begin MFA grant revocation")?; + let changed = sqlx::query( + "DELETE FROM user_mfa_grants WHERE id = ? AND username = ?", + ) + .bind(id) + .bind(&username) + .execute(&mut *tx) + .await + .context("revoke one MFA device grant")? + .rows_affected() + == 1; + if changed { + sqlx::query( + "INSERT INTO audit_log (actor, action, target, diff_json, occurred_at) + VALUES (?, 'mfa.trusted_device.revoke', ?, ?, ?)", + ) + .bind(actor) + .bind(&target) + .bind(&diff) + .bind(now) + .execute(&mut *tx) + .await + .context("audit one MFA device revocation")?; + } + tx.commit().await.context("commit MFA grant revocation")?; + changed + } + ConfigDb::Postgres(pool) => { + let mut tx = pool.begin().await.context("begin MFA grant revocation")?; + let changed = sqlx::query( + "DELETE FROM user_mfa_grants WHERE id = $1 AND username = $2", + ) + .bind(id) + .bind(&username) + .execute(&mut *tx) + .await + .context("revoke one MFA device grant")? + .rows_affected() + == 1; + if changed { + sqlx::query( + "INSERT INTO audit_log (actor, action, target, diff_json, occurred_at) + VALUES ($1, 'mfa.trusted_device.revoke', $2, $3, $4)", + ) + .bind(actor) + .bind(&target) + .bind(&diff) + .bind(now) + .execute(&mut *tx) + .await + .context("audit one MFA device revocation")?; + } + tx.commit().await.context("commit MFA grant revocation")?; + changed + } + }; + Ok(changed) +} + +/// Silent cleanup for already-audited security events (password reset/change, +/// factor reset). Keeping this inside the caller's transaction makes the +/// security mutation and grant invalidation atomic without duplicate audit +/// noise. +pub(crate) async fn delete_all_sqlite( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + username: &str, +) -> Result<()> { + sqlx::query("DELETE FROM user_mfa_grants WHERE username = ?") + .bind(username) + .execute(&mut **tx) + .await + .context("silently revoke MFA grants")?; + Ok(()) +} + +pub(crate) async fn delete_all_postgres( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + username: &str, +) -> Result<()> { + sqlx::query("DELETE FROM user_mfa_grants WHERE username = $1") + .bind(username) + .execute(&mut **tx) + .await + .context("silently revoke MFA grants")?; + Ok(()) +} diff --git a/crates/ruscker-admin/src/db/users.rs b/crates/ruscker-admin/src/db/users.rs index 41c80ff9..00b08694 100644 --- a/crates/ruscker-admin/src/db/users.rs +++ b/crates/ruscker-admin/src/db/users.rs @@ -599,6 +599,7 @@ pub async fn set_password( if res.rows_affected() == 0 { anyhow::bail!("user {username} not found"); } + crate::db::mfa_grants::delete_all_sqlite(&mut tx, &username).await?; sqlx::query( "INSERT INTO audit_log (actor, action, target, diff_json, occurred_at) VALUES (?, 'user.password', ?, NULL, ?)", @@ -628,6 +629,7 @@ pub async fn set_password( if res.rows_affected() == 0 { anyhow::bail!("user {username} not found"); } + crate::db::mfa_grants::delete_all_postgres(&mut tx, &username).await?; sqlx::query( "INSERT INTO audit_log (actor, action, target, diff_json, occurred_at) VALUES ($1, 'user.password', $2, NULL, $3)", @@ -1029,6 +1031,9 @@ pub async fn delete(db: &ConfigDb, username: &str, actor: Option<&str>) -> Resul ConfigDb::Sqlite(pool) => { let mut tx = pool.begin().await.context("begin user delete")?; // Conditional so it can never delete the last admin (#872). + // `user_mfa_grants.username` has ON DELETE CASCADE, so this + // already-audited user deletion revokes grants atomically without + // a duplicate trusted-device audit row. let res = sqlx::query( "DELETE FROM users WHERE username = ? AND (role <> 'admin' diff --git a/crates/ruscker-admin/src/mfa.rs b/crates/ruscker-admin/src/mfa.rs index b0108266..fa630656 100644 --- a/crates/ruscker-admin/src/mfa.rs +++ b/crates/ruscker-admin/src/mfa.rs @@ -6,16 +6,20 @@ //! authenticator-app interoperability profile. use anyhow::{anyhow, Context, Result}; +use chrono::Utc; use qrcode::render::svg; use qrcode::QrCode; use ring::{digest, rand as ring_rand}; +use ruscker_config::Spec; use std::collections::{HashMap, VecDeque}; use std::time::{Duration, Instant}; use totp_rs::{Algorithm, Secret, TOTP}; +use tower_cookies::Cookies; pub const RECOVERY_CODE_COUNT: usize = 10; pub const RECOVERY_CODE_LEN: usize = 10; const RECOVERY_ALPHABET: &[u8; 32] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +pub const DEVICE_COOKIE: &str = "__ruscker_mfa_device"; /// Material shown on the setup screen and persisted only after encryption. pub struct Enrollment { @@ -65,12 +69,36 @@ pub fn render_enrollment(secret_base32: &str, username: &str) -> Result Result { + Ok(verify_totp_step(secret_base32, username, code)?.is_some()) +} + +/// Verify a TOTP against the current, previous, or next 30-second step and +/// return the exact accepted step for persistent replay prevention. +pub fn verify_totp_step(secret_base32: &str, username: &str, code: &str) -> Result> { if code.len() != 6 || !code.bytes().all(|b| b.is_ascii_digit()) { - return Ok(false); + return Ok(None); } - totp(secret_base32, username)? - .check_current(code) - .context("read system time for TOTP") + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("read system time for TOTP")? + .as_secs(); + let current = i64::try_from(now / 30).context("TOTP step exceeds i64")?; + let profile = totp(secret_base32, username)?; + // Prefer the current step if a six-digit collision occurs across the skew + // window. The stored monotonic step then rejects every older candidate. + for offset in [0_i64, -1, 1] { + let Some(step) = current.checked_add(offset) else { + continue; + }; + let Ok(timestamp) = u64::try_from(step.saturating_mul(30)) else { + continue; + }; + let expected = profile.generate(timestamp); + if constant_time_equal(code.as_bytes(), expected.as_bytes()) { + return Ok(Some(step)); + } + } + Ok(None) } /// Generate ten high-entropy, human-readable one-time recovery codes. @@ -139,6 +167,137 @@ fn constant_time_equal(actual: &[u8], expected: &[u8]) -> bool { ring::constant_time::verify_slices_are_equal(actual, expected).is_ok() } +/// Generate the 32-byte bearer token placed after the grant id in the cookie. +pub fn generate_device_token() -> Result { + let rng = ring_rand::SystemRandom::new(); + let mut token = [0u8; 32]; + ring_rand::SecureRandom::fill(&rng, &mut token) + .map_err(|_| anyhow!("generate MFA device token"))?; + Ok(hex::encode(token)) +} + +/// Salt and hash a high-entropy trusted-device bearer token. +pub fn hash_device_token(token: &str) -> Result { + let rng = ring_rand::SystemRandom::new(); + let mut salt = [0u8; 16]; + ring_rand::SecureRandom::fill(&rng, &mut salt) + .map_err(|_| anyhow!("generate MFA device-token salt"))?; + let mut input = Vec::with_capacity(salt.len() + token.len()); + input.extend_from_slice(&salt); + input.extend_from_slice(token.as_bytes()); + let hash = digest::digest(&digest::SHA256, &input); + Ok(format!("{}:{}", hex::encode(salt), hex::encode(hash.as_ref()))) +} + +/// Constant-time verification of a trusted-device token against its stored +/// salted hash string. +pub fn verify_device_token(token: &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 mut input = Vec::with_capacity(salt.len() + token.len()); + input.extend_from_slice(&salt); + input.extend_from_slice(token.as_bytes()); + let actual = digest::digest(&digest::SHA256, &input); + constant_time_equal(actual.as_ref(), &expected) +} + +/// One-way binding for the opaque admin-session bearer. The raw session id +/// never enters the grant table. +pub fn session_binding(session_id: &str) -> String { + hex::encode(digest::digest(&digest::SHA256, session_id.as_bytes()).as_ref()) +} + +pub fn device_cookie_parts(value: &str) -> Option<(&str, &str)> { + let (id, token) = value.split_once('.')?; + if id.is_empty() || token.len() != 64 || !token.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + Some((id, token)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MfaDecision { + Satisfied, + ChallengeRequired, + EnrollmentRequired, +} + +/// Decide whether this browser's latest device proof satisfies `spec`. +/// Database, cookie, token, factor, expiry, age, and session mismatches all +/// fail closed toward a challenge; only a confirmed-factor absence produces +/// `EnrollmentRequired`. +pub async fn evaluate( + state: &crate::AppState, + username: &str, + session_id: &str, + cookies: &Cookies, + spec: &Spec, +) -> MfaDecision { + if !spec.effective_require_mfa() { + return MfaDecision::Satisfied; + } + let Some(db) = state.db.as_ref() else { + return MfaDecision::ChallengeRequired; + }; + let factor = match crate::db::mfa::fetch(db, username).await { + Ok(Some(row)) if row.confirmed_at.is_some() => row, + Ok(_) => return MfaDecision::EnrollmentRequired, + Err(err) => { + tracing::warn!(error = ?err, %username, "MFA decision factor fetch failed"); + return MfaDecision::ChallengeRequired; + } + }; + let Some(cookie) = cookies.get(DEVICE_COOKIE) else { + return MfaDecision::ChallengeRequired; + }; + let Some((id, token)) = device_cookie_parts(cookie.value()) else { + return MfaDecision::ChallengeRequired; + }; + let grant = match crate::db::mfa_grants::fetch_valid(db, id).await { + Ok(Some(grant)) => grant, + Ok(None) => return MfaDecision::ChallengeRequired, + Err(err) => { + tracing::warn!(error = ?err, %username, "MFA decision grant fetch failed"); + return MfaDecision::ChallengeRequired; + } + }; + let now = Utc::now(); + if !verify_device_token(token, &grant.token_hash) + || grant.username != crate::db::users::normalize_username(username) + || grant.expires_at <= now + || Some(grant.factor_confirmed_at) != factor.confirmed_at + { + return MfaDecision::ChallengeRequired; + } + + let validity_days = spec.effective_mfa_validity_days(); + if validity_days == 0 { + return if constant_time_equal( + grant.session_binding.as_bytes(), + session_binding(session_id).as_bytes(), + ) { + MfaDecision::Satisfied + } else { + MfaDecision::ChallengeRequired + }; + } + let age = now.signed_duration_since(grant.mfa_verified_at); + if age < chrono::Duration::zero() + || age > chrono::Duration::days(i64::from(validity_days)) + { + MfaDecision::ChallengeRequired + } else { + MfaDecision::Satisfied + } +} + /// Per-username confirmation limiter. A correct code clears that username's /// failures; five wrong codes inside 60 seconds produce a friendly 429. #[derive(Debug)] @@ -196,6 +355,11 @@ pub static CONFIRM_LIMITER: std::sync::LazyLock = pub static REAUTH_LIMITER: std::sync::LazyLock = std::sync::LazyLock::new(|| ConfirmRateLimiter::new(5, Duration::from_secs(60))); +/// Challenge attempts have an independent budget from enrollment confirms, +/// so setup typos cannot lock an already-enrolled user's app proof flow. +pub static CHALLENGE_LIMITER: std::sync::LazyLock = + std::sync::LazyLock::new(|| ConfirmRateLimiter::new(5, Duration::from_secs(60))); + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ruscker-admin/src/routes/admin/mfa.rs b/crates/ruscker-admin/src/routes/admin/mfa.rs index 7077c20c..d46cd082 100644 --- a/crates/ruscker-admin/src/routes/admin/mfa.rs +++ b/crates/ruscker-admin/src/routes/admin/mfa.rs @@ -3,26 +3,31 @@ use askama::Template; use axum::{ extract::{Form, Query, State}, - http::{header::CACHE_CONTROL, header::RETRY_AFTER, StatusCode}, - response::{IntoResponse, Response}, + http::{header::CACHE_CONTROL, header::RETRY_AFTER, HeaderMap, StatusCode}, + response::{IntoResponse, Redirect, Response}, routing::{get, post}, Router, }; use serde::Deserialize; -use crate::auth::{AdminSession, Role}; -use axum::http::HeaderMap; -use tower_cookies::{Cookie, Cookies}; +use crate::auth::{AdminSession, Role, COOKIE_NAME}; use crate::db; use crate::i18n::{Locale, Locales}; use crate::theme::Theme; use crate::AppState; +use tower_cookies::{Cookie, Cookies}; 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)) + .route( + "/admin/account/mfa/challenge", + get(challenge).post(challenge_submit), + ) + .route("/admin/account/mfa/device/forget", post(forget_device)) + .route("/admin/account/mfa/devices/revoke", post(revoke_devices)) } #[derive(Template)] @@ -90,6 +95,27 @@ impl AccountMfaRecoveryPage<'_> { } } +#[derive(Template)] +#[template(path = "admin/account_mfa_challenge.html")] +struct AccountMfaChallengePage<'a> { + locale: Locale, + theme: Theme, + locales: &'a Locales, + locales_all: &'static [Locale], + base: std::sync::Arc, + nav_section: &'static str, + role: Role, + break_glass: bool, + error: &'static str, + next: String, +} + +impl AccountMfaChallengePage<'_> { + fn t(&self, key: &str) -> String { + self.locales.t(self.locale, key, None) + } +} + #[derive(Debug, Deserialize, Default)] struct NextQuery { next: Option, @@ -107,6 +133,13 @@ struct ConfirmForm { next: Option, } +#[derive(Debug, Deserialize)] +struct ChallengeForm { + code: String, + kind: 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(); @@ -222,6 +255,42 @@ fn clear_ceremony_cookie(state: &AppState, cookies: &Cookies) { cookies.remove(c); } +fn device_cookie_path(base: &str) -> String { + if base.is_empty() { + "/".to_string() + } else { + format!("{base}/") + } +} + +fn issue_device_cookie( + state: &AppState, + cookies: &Cookies, + headers: &HeaderMap, + id: &str, + token: &str, +) { + let mut c = Cookie::new(crate::mfa::DEVICE_COOKIE, format!("{id}.{token}")); + c.set_path(device_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::days(i64::from( + ruscker_config::MAX_MFA_VALIDITY_DAYS, + ))); + cookies.add(c); +} + +fn clear_device_cookie(state: &AppState, cookies: &Cookies) { + let mut c = Cookie::new(crate::mfa::DEVICE_COOKIE, ""); + // The removal path must exactly match the issuing path (#923). + c.set_path(device_cookie_path(&state.base_path)); + cookies.remove(c); +} + async fn start( session: AdminSession, State(state): State, @@ -502,14 +571,14 @@ async fn confirm( .insert(RETRY_AFTER, "60".parse().unwrap()); return response; } - let valid = match crate::mfa::verify_totp(secret, username, form.code.trim()) { - Ok(valid) => valid, + let accepted_step = match crate::mfa::verify_totp_step(secret, username, form.code.trim()) { + Ok(step) => step, Err(err) => { tracing::error!(error = ?err, %username, "verify enrollment TOTP failed"); return (StatusCode::INTERNAL_SERVER_ERROR, "MFA verification error").into_response(); } }; - if !valid { + let Some(accepted_step) = accepted_step else { return render_setup( &state, session.role, @@ -520,6 +589,25 @@ async fn confirm( next, StatusCode::UNAUTHORIZED, ); + }; + match db::mfa::record_used_step(db, username, accepted_step).await { + Ok(true) => {} + Ok(false) => { + return render_setup( + &state, + session.role, + loc, + theme, + enrollment, + "wrong-code", + next, + StatusCode::UNAUTHORIZED, + ); + } + Err(err) => { + tracing::error!(error = ?err, %username, "record enrollment TOTP step failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "MFA verification error").into_response(); + } } let codes = match crate::mfa::generate_recovery_codes() { @@ -570,3 +658,348 @@ async fn confirm( .insert(CACHE_CONTROL, "no-store".parse().unwrap()); response } + +#[allow(clippy::too_many_arguments)] +fn render_challenge( + state: &AppState, + role: Role, + loc: Locale, + theme: Theme, + break_glass: bool, + error: &'static str, + next: String, + status: StatusCode, +) -> Response { + let page = AccountMfaChallengePage { + locale: loc, + theme, + locales: &state.locales, + locales_all: &Locale::ALL, + base: state.base_path.clone(), + nav_section: "account", + role, + break_glass, + 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 challenge failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response() + } + } +} + +async fn challenge( + session: AdminSession, + State(state): State, + loc: Locale, + theme: Theme, + Query(query): Query, +) -> Response { + let next = safe_next(query.next.as_deref(), &state.base_path); + let Some(username) = session.actor.as_deref() else { + return render_challenge( + &state, + session.role, + loc, + theme, + true, + "", + next, + StatusCode::OK, + ); + }; + let Some(db) = state.db.as_ref() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "no database — start with --db", + ) + .into_response(); + }; + match db::mfa::is_enrolled(db, username).await { + Ok(true) => render_challenge( + &state, + session.role, + loc, + theme, + false, + "", + next, + StatusCode::OK, + ), + Ok(false) => { + let path = format!("{}/admin/account/mfa", state.base_path); + Redirect::to(&crate::routes::with_next_query(&path, &next)).into_response() + } + Err(err) => { + tracing::error!(error = ?err, %username, "fetch MFA status for challenge failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response() + } + } +} + +async fn challenge_submit( + 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.as_deref() else { + return render_challenge( + &state, + session.role, + loc, + theme, + true, + "", + next, + StatusCode::FORBIDDEN, + ); + }; + 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_some() => row, + Ok(_) => { + let path = format!("{}/admin/account/mfa", state.base_path); + return Redirect::to(&crate::routes::with_next_query(&path, &next)).into_response(); + } + Err(err) => { + tracing::error!(error = ?err, %username, "fetch MFA factor for challenge failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response(); + } + }; + if !crate::mfa::CHALLENGE_LIMITER.try_reserve(username) { + let mut response = render_challenge( + &state, + session.role, + loc, + theme, + false, + "rate-limited", + next, + StatusCode::TOO_MANY_REQUESTS, + ); + response + .headers_mut() + .insert(RETRY_AFTER, "60".parse().unwrap()); + return response; + } + + let audit_action = match form.kind.as_str() { + "totp" => { + let plaintext = match state.master_key.decrypt(&row.secret_enc, &row.secret_nonce) { + Ok(plaintext) => plaintext, + Err(err) => { + tracing::error!(error = ?err, %username, "decrypt MFA challenge secret failed"); + return key_missing(&state, loc); + } + }; + let secret = match std::str::from_utf8(&plaintext) { + Ok(secret) => secret, + Err(err) => { + tracing::error!(error = ?err, %username, "MFA challenge secret is not UTF-8"); + return (StatusCode::INTERNAL_SERVER_ERROR, "invalid MFA state") + .into_response(); + } + }; + let step = match crate::mfa::verify_totp_step(secret, username, form.code.trim()) { + Ok(step) => step, + Err(err) => { + tracing::error!(error = ?err, %username, "verify challenge TOTP failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "MFA verification error") + .into_response(); + } + }; + let Some(step) = step else { + return render_challenge( + &state, + session.role, + loc, + theme, + false, + "wrong-code", + next, + StatusCode::UNAUTHORIZED, + ); + }; + match db::mfa::record_used_step(db, username, step).await { + Ok(true) => "mfa.verify", + Ok(false) => { + return render_challenge( + &state, + session.role, + loc, + theme, + false, + "replayed", + next, + StatusCode::UNAUTHORIZED, + ); + } + Err(err) => { + tracing::error!(error = ?err, %username, "record challenge TOTP step failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "MFA verification error") + .into_response(); + } + } + } + "recovery" => match db::mfa::consume_recovery_code(db, username, form.code.trim()).await { + Ok(true) => "mfa.recovery_used", + Ok(false) => { + return render_challenge( + &state, + session.role, + loc, + theme, + false, + "wrong-code", + next, + StatusCode::UNAUTHORIZED, + ); + } + Err(err) => { + tracing::error!(error = ?err, %username, "consume MFA recovery code failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "MFA verification error") + .into_response(); + } + }, + _ => { + return render_challenge( + &state, + session.role, + loc, + theme, + false, + "wrong-code", + next, + StatusCode::UNAUTHORIZED, + ); + } + }; + + let Some(session_id) = cookies.get(COOKIE_NAME).map(|c| c.value().to_string()) else { + return (StatusCode::UNAUTHORIZED, "admin session cookie missing").into_response(); + }; + let token = match crate::mfa::generate_device_token() { + Ok(token) => token, + Err(err) => { + tracing::error!(error = ?err, %username, "generate MFA device token failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "MFA grant error").into_response(); + } + }; + let token_hash = match crate::mfa::hash_device_token(&token) { + Ok(hash) => hash, + Err(err) => { + tracing::error!(error = ?err, %username, "hash MFA device token failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "MFA grant error").into_response(); + } + }; + let verified_at = chrono::Utc::now(); + let expires_at = verified_at + + chrono::Duration::days(i64::from(ruscker_config::MAX_MFA_VALIDITY_DAYS)); + let Some(factor_confirmed_at) = row.confirmed_at else { + return (StatusCode::CONFLICT, "MFA factor is not confirmed").into_response(); + }; + let id = match db::mfa_grants::create( + db, + username, + &token_hash, + &crate::mfa::session_binding(&session_id), + factor_confirmed_at, + verified_at, + expires_at, + ) + .await + { + Ok(id) => id, + Err(err) => { + tracing::error!(error = ?err, %username, "create MFA device grant failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "MFA grant error").into_response(); + } + }; + if let Err(err) = db::audit::record( + db, + username, + audit_action, + &format!("user:{username}"), + None, + ) + .await + { + tracing::error!(error = ?err, %username, "audit MFA proof failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "audit error").into_response(); + } + crate::mfa::CHALLENGE_LIMITER.record_success(username); + issue_device_cookie(&state, &cookies, &headers, &id, &token); + let destination = if next.is_empty() { + format!("{}/admin/account/mfa", state.base_path) + } else { + format!("{}{}", state.base_path, next) + }; + Redirect::to(&destination).into_response() +} + +async fn forget_device( + session: AdminSession, + State(state): State, + cookies: Cookies, +) -> Response { + let Some(username) = session.actor.as_deref() else { + return (StatusCode::FORBIDDEN, "break-glass session has no MFA device").into_response(); + }; + let Some(db) = state.db.as_ref() else { + return (StatusCode::SERVICE_UNAVAILABLE, "no database").into_response(); + }; + if let Some(cookie) = cookies.get(crate::mfa::DEVICE_COOKIE) { + if let Some((id, _)) = crate::mfa::device_cookie_parts(cookie.value()) { + if let Err(err) = db::mfa_grants::revoke_one(db, id, username, username).await { + tracing::error!(error = ?err, %username, "forget MFA device failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response(); + } + } + } + clear_device_cookie(&state, &cookies); + Redirect::to(&format!("{}/admin/account/mfa", state.base_path)).into_response() +} + +async fn revoke_devices( + session: AdminSession, + State(state): State, + cookies: Cookies, +) -> Response { + let Some(username) = session.actor.as_deref() else { + return (StatusCode::FORBIDDEN, "break-glass session has no MFA devices").into_response(); + }; + let Some(db) = state.db.as_ref() else { + return (StatusCode::SERVICE_UNAVAILABLE, "no database").into_response(); + }; + if let Err(err) = + db::mfa_grants::revoke_all(db, username, username, "all-devices").await + { + tracing::error!(error = ?err, %username, "revoke all MFA devices failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response(); + } + clear_device_cookie(&state, &cookies); + Redirect::to(&format!("{}/admin/account/mfa", state.base_path)).into_response() +} diff --git a/crates/ruscker-admin/templates/admin/account_mfa.html b/crates/ruscker-admin/templates/admin/account_mfa.html index a2a4d650..683cc7ed 100644 --- a/crates/ruscker-admin/templates/admin/account_mfa.html +++ b/crates/ruscker-admin/templates/admin/account_mfa.html @@ -25,6 +25,22 @@

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

{{ self.t("admin-mfa-enrolled-since") }} {{ enrolled_at }}

{{ self.t("admin-mfa-reenroll-note") }}

+
+
+ +
+
+ +
+
{% if next != "" %}