diff --git a/crates/ruscker-admin/assets/i18n/en/landing.ftl b/crates/ruscker-admin/assets/i18n/en/landing.ftl index 027e484c..0b20bff8 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 = Forget all devices +admin-mfa-revoke-all-confirm = Forget all trusted devices? Every browser will have to prove 2FA again on its next access to a protected app. Login sessions stay active. 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..ec256030 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 = Olvidar todos los dispositivos +admin-mfa-revoke-all-confirm = ¿Olvidar todos los dispositivos de confianza? Cada navegador deberá probar el 2FA de nuevo en el próximo acceso a una app protegida. Las sesiones de inicio permanecen activas. 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..7fcf19d9 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 = Oublier tous les appareils +admin-mfa-revoke-all-confirm = Oublier tous les appareils de confiance ? Chaque navigateur devra prouver le 2FA à nouveau au prochain accès à une app protégée. Les sessions de connexion restent actives. 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..7325d55c 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 = Esquecer todos os dispositivos +admin-mfa-revoke-all-confirm = Esquecer todos os dispositivos confiáveis? Cada navegador precisará provar o 2FA de novo no próximo acesso a um app protegido. As sessões de login continuam ativas. 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..2d2ef5d2 --- /dev/null +++ b/crates/ruscker-admin/migrations-pg/0030_user_mfa_grants.sql @@ -0,0 +1,25 @@ +-- 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, + -- Epoch do fator no momento da emissão: validado na leitura contra + -- user_mfa.security_epoch, então um grant emitido sob uma época já + -- revogada (corrida MVCC no pg) nunca é aceito (#1005). + security_epoch BIGINT NOT NULL, + mfa_verified_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + -- One live grant per browser-session (#1005). + UNIQUE(username, session_binding) +); + +CREATE INDEX idx_user_mfa_grants_username + ON user_mfa_grants(username); + +-- Época de revogação de confiança no fator (ver coluna acima). Vive na +-- 0030 (não na 0029) porque a 0029 pertence à fatia anterior e migrações +-- já aplicadas são imutáveis (checksum do sqlx). +ALTER TABLE user_mfa ADD COLUMN security_epoch BIGINT NOT NULL DEFAULT 0; 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..409c349a --- /dev/null +++ b/crates/ruscker-admin/migrations/0030_user_mfa_grants.sql @@ -0,0 +1,29 @@ +-- 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, + -- Epoch do fator no momento da emissão: validado na leitura contra + -- user_mfa.security_epoch, então um grant emitido sob uma época já + -- revogada (corrida MVCC no pg) nunca é aceito (#1005). + security_epoch BIGINT NOT NULL, + mfa_verified_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + -- One live grant per browser-session (#1005): a re-challenge UPSERTs + -- the single row, so a browser can never hold two valid grants and a + -- stale cookie after a revocation just gets a fresh grant. + UNIQUE(username, session_binding) +); + +CREATE INDEX idx_user_mfa_grants_username + ON user_mfa_grants(username); + +-- Época de revogação de confiança no fator (ver coluna acima). Vive na +-- 0030 (não na 0029) porque a 0029 pertence à fatia anterior e migrações +-- já aplicadas são imutáveis (checksum do sqlx). +ALTER TABLE user_mfa ADD COLUMN security_epoch BIGINT NOT NULL DEFAULT 0; 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..46de957f 100644 --- a/crates/ruscker-admin/src/db/mfa.rs +++ b/crates/ruscker-admin/src/db/mfa.rs @@ -24,6 +24,9 @@ pub struct MfaRow { pub updated_at: DateTime, /// Reserved for slice 3's accepted-step replay prevention. pub last_used_step: Option, + /// Trust-revocation epoch: grant issuance is conditional on the value + /// read before verification, so revocations racing a challenge win. + pub security_epoch: i64, } type StoredRow = ( @@ -35,6 +38,7 @@ type StoredRow = ( DateTime, DateTime, Option, + i64, ); /// Start or replace a pending enrollment. A confirmed factor is never @@ -239,7 +243,7 @@ pub async fn fetch(db: &ConfigDb, username: &str) -> Result> { ConfigDb::Sqlite(pool) => { sqlx::query_as( "SELECT username, secret_enc, secret_nonce, ceremony, confirmed_at, - created_at, updated_at, last_used_step + created_at, updated_at, last_used_step, security_epoch FROM user_mfa WHERE username = ?", ) .bind(&username) @@ -249,7 +253,7 @@ pub async fn fetch(db: &ConfigDb, username: &str) -> Result> { ConfigDb::Postgres(pool) => { sqlx::query_as( "SELECT username, secret_enc, secret_nonce, ceremony, confirmed_at, - created_at, updated_at, last_used_step + created_at, updated_at, last_used_step, security_epoch FROM user_mfa WHERE username = $1", ) .bind(&username) @@ -268,6 +272,7 @@ pub async fn fetch(db: &ConfigDb, username: &str) -> Result> { created_at, updated_at, last_used_step, + security_epoch, )| { MfaRow { username, @@ -278,6 +283,7 @@ pub async fn fetch(db: &ConfigDb, username: &str) -> Result> { created_at, updated_at, last_used_step, + security_epoch, } }, )) @@ -343,6 +349,43 @@ pub async fn replace_recovery_codes( Ok(()) } +/// Find the id of the unused recovery code matching `code`, WITHOUT +/// consuming it. Consumption happens inside [`crate::db::mfa_grants::issue`] +/// so a failed grant issuance rolls the spend back — a finite code must +/// never be burned with nothing to show for it (codex review, #1005). +pub async fn find_recovery_candidate( + db: &ConfigDb, + username: &str, + code: &str, +) -> Result> { + let username = crate::db::users::normalize_username(username); + let rows: Vec<(String, String)> = match db { + ConfigDb::Sqlite(pool) => { + sqlx::query_as( + "SELECT id, code_hash FROM user_mfa_recovery + WHERE username = ? AND used_at IS NULL", + ) + .bind(&username) + .fetch_all(pool) + .await + } + ConfigDb::Postgres(pool) => { + sqlx::query_as( + "SELECT id, code_hash FROM user_mfa_recovery + WHERE username = $1 AND used_at IS NULL", + ) + .bind(&username) + .fetch_all(pool) + .await + } + } + .context("fetch recovery candidates")?; + Ok(rows + .into_iter() + .find(|(_, hash)| crate::mfa::verify_recovery_code(code, hash)) + .map(|(id, _)| id)) +} + /// 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 { @@ -440,6 +483,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 +523,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 +548,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 +676,43 @@ 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), + 0, + ) + .await + .unwrap() + .expect("grant issued under current epoch"); + 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..139b766f --- /dev/null +++ b/crates/ruscker-admin/src/db/mfa_grants.rs @@ -0,0 +1,655 @@ +//! 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, + /// Epoch the factor had when this grant was issued — validated against + /// the live user_mfa.security_epoch on every evaluate, so a grant that + /// slipped past a racing revocation (pg MVCC) is never accepted. + pub security_epoch: i64, + pub mfa_verified_at: DateTime, + pub expires_at: DateTime, + pub created_at: DateTime, +} + +type StoredRow = ( + String, + String, + String, + String, + DateTime, + i64, + DateTime, + DateTime, + DateTime, +); + +#[allow(clippy::too_many_arguments)] // grant issuance is one atomic fact +pub async fn create( + db: &ConfigDb, + username: &str, + token_hash: &str, + session_binding: &str, + factor_confirmed_at: DateTime, + verified_at: DateTime, + expires_at: DateTime, + expected_epoch: i64, +) -> Result> { + // INSERT … SELECT conditioned on the security epoch read BEFORE the + // TOTP verification (codex review, #1005): a revocation (password + // set/change, forget-all) bumps the epoch in the same transaction + // that deletes the grants, so a challenge that was in flight across + // the revocation inserts 0 rows instead of resurrecting device + // trust. `None` = stale epoch; the route re-challenges. + let username = crate::db::users::normalize_username(username); + let id = uuid::Uuid::new_v4().to_string(); + let inserted = match db { + ConfigDb::Sqlite(pool) => sqlx::query( + "INSERT INTO user_mfa_grants + (id, username, token_hash, session_binding, factor_confirmed_at, + security_epoch, mfa_verified_at, expires_at, created_at) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ? + FROM user_mfa + WHERE username = ? AND security_epoch = ?", + ) + .bind(&id) + .bind(&username) + .bind(token_hash) + .bind(session_binding) + .bind(factor_confirmed_at) + .bind(expected_epoch) + .bind(verified_at) + .bind(expires_at) + .bind(verified_at) + .bind(&username) + .bind(expected_epoch) + .execute(pool) + .await + .with_context(|| format!("create MFA device grant for {username}"))? + .rows_affected(), + ConfigDb::Postgres(pool) => sqlx::query( + "INSERT INTO user_mfa_grants + (id, username, token_hash, session_binding, factor_confirmed_at, + security_epoch, mfa_verified_at, expires_at, created_at) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9 + FROM user_mfa + WHERE username = $10 AND security_epoch = $11", + ) + .bind(&id) + .bind(&username) + .bind(token_hash) + .bind(session_binding) + .bind(factor_confirmed_at) + .bind(expected_epoch) + .bind(verified_at) + .bind(expires_at) + .bind(verified_at) + .bind(&username) + .bind(expected_epoch) + .execute(pool) + .await + .with_context(|| format!("create MFA device grant for {username}"))? + .rows_affected(), + }; + Ok((inserted == 1).then_some(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, security_epoch, 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, security_epoch, 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, + security_epoch, + mfa_verified_at, + expires_at, + created_at, + )| GrantRow { + id, + username, + token_hash, + session_binding, + factor_confirmed_at, + security_epoch, + 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")?; + // Lock ORDER: bump the epoch (locks user_mfa) BEFORE deleting + // grants, matching issue()'s user_mfa-then-grants order — else a + // re-challenge and a revocation grab the two rows in opposite + // order and deadlock on Postgres (codex review, #1005). The bump + // also stops an in-flight challenge that read the old epoch from + // issuing. + sqlx::query( + "UPDATE user_mfa SET security_epoch = security_epoch + 1 WHERE username = ?", + ) + .bind(&username) + .execute(&mut *tx) + .await + .context("bump MFA security epoch")?; + 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")?; + // Lock ORDER (see the SQLite arm): user_mfa before grants. + sqlx::query( + "UPDATE user_mfa SET security_epoch = security_epoch + 1 WHERE username = $1", + ) + .bind(&username) + .execute(&mut *tx) + .await + .context("bump MFA security epoch")?; + 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) +} + + +/// Why [`issue`] refused to mint a grant. Every case rolls the whole +/// transaction back — including a recovery-code consumption or TOTP-step +/// spend — so a finite code is never burned without a grant to show for it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IssueRefusal { + /// A revocation moved the security epoch mid-challenge. + StaleEpoch, + /// The recovery code raced another consumer. + RecoverySpent, + /// The TOTP step was already consumed (replay or a concurrent + /// challenge won the race). + Replayed, +} + +/// Issue (or rotate) the trusted-device grant for one browser-session, one +/// transaction per dialect. UPSERT on `(username, session_binding)`: a +/// re-challenge from the same browser replaces its single row (new id + +/// token, so the old cookie dies), a stale cookie after a revocation just +/// gets a fresh grant, and two concurrent challenges from one browser can +/// never leave two live grants (#1005). Lock order is uniformly +/// user_mfa -> user_mfa_grants -> user_mfa_recovery, matching the +/// revocation/reset paths so a challenge can't deadlock them on Postgres. +/// The epoch-conditional source (`SELECT ... FROM user_mfa WHERE +/// security_epoch = ?`) makes a revocation that raced this issuance win. +#[allow(clippy::too_many_arguments)] // grant issuance is one atomic fact +pub async fn issue( + db: &ConfigDb, + username: &str, + token_hash: &str, + session_binding: &str, + factor_confirmed_at: DateTime, + verified_at: DateTime, + expires_at: DateTime, + expected_epoch: i64, + consume_recovery_id: Option<&str>, + consume_totp_step: Option, + audit_action: &str, + actor: &str, +) -> Result> { + let username = crate::db::users::normalize_username(username); + let id = uuid::Uuid::new_v4().to_string(); + let target = format!("user:{username}"); + let now = Utc::now(); + match db { + ConfigDb::Sqlite(pool) => { + let mut tx = pool.begin().await.context("begin MFA grant issue")?; + // Lock + validate user_mfa FIRST, on EVERY path (codex review + // r9/r10). A no-op conditional UPDATE (not a SELECT) is used so + // this transaction takes the WRITE lock immediately: SQLite's + // deferred transaction would otherwise establish a read snapshot, + // and a revocation committing before our later write would fail + // with SQLITE_BUSY_SNAPSHOT (a 500) instead of a clean recheck. + // rows_affected == 1 means the epoch still matched under the + // lock; 0 means a revocation raced us → StaleEpoch rollback (the + // recovery-code spend below never happens). + let epoch_ok = sqlx::query( + "UPDATE user_mfa SET security_epoch = security_epoch + WHERE username = ? AND security_epoch = ?", + ) + .bind(&username) + .bind(expected_epoch) + .execute(&mut *tx) + .await + .context("lock+validate MFA epoch during issue")? + .rows_affected(); + if epoch_ok != 1 { + let _ = tx.rollback().await; + return Ok(Err(IssueRefusal::StaleEpoch)); + } + // Lock order — user_mfa (TOTP replay guard). + if let Some(step) = consume_totp_step { + let fresh = 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(&mut *tx) + .await + .context("consume TOTP step during issue")? + .rows_affected(); + if fresh != 1 { + let _ = tx.rollback().await; + return Ok(Err(IssueRefusal::Replayed)); + } + } + // Lock order 2/3 — user_mfa_grants (UPSERT the browser's row). + let inserted = sqlx::query( + "INSERT INTO user_mfa_grants + (id, username, token_hash, session_binding, factor_confirmed_at, + security_epoch, mfa_verified_at, expires_at, created_at) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ? + FROM user_mfa + WHERE username = ? AND security_epoch = ? + ON CONFLICT(username, session_binding) DO UPDATE SET + id = excluded.id, + token_hash = excluded.token_hash, + factor_confirmed_at = excluded.factor_confirmed_at, + security_epoch = excluded.security_epoch, + mfa_verified_at = excluded.mfa_verified_at, + expires_at = excluded.expires_at, + created_at = excluded.created_at", + ) + .bind(&id) + .bind(&username) + .bind(token_hash) + .bind(session_binding) + .bind(factor_confirmed_at) + .bind(expected_epoch) + .bind(verified_at) + .bind(expires_at) + .bind(verified_at) + .bind(&username) + .bind(expected_epoch) + .execute(&mut *tx) + .await + .context("issue MFA device grant")? + .rows_affected(); + if inserted != 1 { + let _ = tx.rollback().await; + return Ok(Err(IssueRefusal::StaleEpoch)); + } + // Lock order 3/3 — user_mfa_recovery (consume, if that was the proof). + if let Some(rid) = consume_recovery_id { + let spent = sqlx::query( + "UPDATE user_mfa_recovery SET used_at = ? + WHERE id = ? AND username = ? AND used_at IS NULL", + ) + .bind(now) + .bind(rid) + .bind(&username) + .execute(&mut *tx) + .await + .context("consume recovery code during issue")? + .rows_affected(); + if spent != 1 { + let _ = tx.rollback().await; + return Ok(Err(IssueRefusal::RecoverySpent)); + } + } + // Opportunistic retention (codex review r9): session-only MFA + // mints a row per login, and expired rows are otherwise only + // filtered at read time — never deleted until a revocation. Sweep + // this user's expired grants on each issuance so the table can't + // grow unboundedly on long-running installs. + sqlx::query("DELETE FROM user_mfa_grants WHERE username = ? AND expires_at < ?") + .bind(&username) + .bind(now) + .execute(&mut *tx) + .await + .context("sweep expired MFA grants")?; + sqlx::query( + "INSERT INTO audit_log (actor, action, target, diff_json, occurred_at) + VALUES (?, ?, ?, NULL, ?)", + ) + .bind(actor) + .bind(audit_action) + .bind(&target) + .bind(now) + .execute(&mut *tx) + .await + .context("audit MFA proof")?; + tx.commit().await.context("commit MFA grant issue")?; + } + ConfigDb::Postgres(pool) => { + let mut tx = pool.begin().await.context("begin MFA grant issue")?; + // Parent-before-child lock order (codex review r10): lock the + // parent `users` row (KEY SHARE — blocks deletion, allows normal + // updates) BEFORE user_mfa. The grant INSERT's FK check needs a + // key-share lock on this parent anyway; user deletion locks + // `users` then cascades to the children, so taking the parent + // first here makes both paths lock parent→child and can't + // deadlock. + sqlx::query("SELECT 1 FROM users WHERE username = $1 FOR KEY SHARE") + .bind(&username) + .fetch_optional(&mut *tx) + .await + .context("lock parent user row during issue")?; + // Lock + validate user_mfa on EVERY path (codex review r9/r10): + // FOR UPDATE so a revocation's epoch bump can't slip between this + // and the grant insert on the recovery path (no TOTP-step UPDATE + // to take the lock). A raced revocation fails the epoch check + // cleanly as StaleEpoch. + let live_epoch: Option<(i64,)> = sqlx::query_as( + "SELECT security_epoch FROM user_mfa WHERE username = $1 FOR UPDATE", + ) + .bind(&username) + .fetch_optional(&mut *tx) + .await + .context("lock MFA row during issue")?; + if live_epoch.map(|(e,)| e) != Some(expected_epoch) { + let _ = tx.rollback().await; + return Ok(Err(IssueRefusal::StaleEpoch)); + } + // Lock order — user_mfa (TOTP replay guard). + if let Some(step) = consume_totp_step { + let fresh = sqlx::query( + "UPDATE user_mfa SET last_used_step = $1 + WHERE username = $2 + AND (last_used_step IS NULL OR last_used_step < $3)", + ) + .bind(step) + .bind(&username) + .bind(step) + .execute(&mut *tx) + .await + .context("consume TOTP step during issue")? + .rows_affected(); + if fresh != 1 { + let _ = tx.rollback().await; + return Ok(Err(IssueRefusal::Replayed)); + } + } + // Lock order 2/3 — user_mfa_grants (UPSERT the browser's row). + let inserted = sqlx::query( + "INSERT INTO user_mfa_grants + (id, username, token_hash, session_binding, factor_confirmed_at, + security_epoch, mfa_verified_at, expires_at, created_at) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9 + FROM user_mfa + WHERE username = $10 AND security_epoch = $11 + ON CONFLICT(username, session_binding) DO UPDATE SET + id = excluded.id, + token_hash = excluded.token_hash, + factor_confirmed_at = excluded.factor_confirmed_at, + security_epoch = excluded.security_epoch, + mfa_verified_at = excluded.mfa_verified_at, + expires_at = excluded.expires_at, + created_at = excluded.created_at", + ) + .bind(&id) + .bind(&username) + .bind(token_hash) + .bind(session_binding) + .bind(factor_confirmed_at) + .bind(expected_epoch) + .bind(verified_at) + .bind(expires_at) + .bind(verified_at) + .bind(&username) + .bind(expected_epoch) + .execute(&mut *tx) + .await + .context("issue MFA device grant")? + .rows_affected(); + if inserted != 1 { + let _ = tx.rollback().await; + return Ok(Err(IssueRefusal::StaleEpoch)); + } + // Lock order 3/3 — user_mfa_recovery. + if let Some(rid) = consume_recovery_id { + let spent = sqlx::query( + "UPDATE user_mfa_recovery SET used_at = $1 + WHERE id = $2 AND username = $3 AND used_at IS NULL", + ) + .bind(now) + .bind(rid) + .bind(&username) + .execute(&mut *tx) + .await + .context("consume recovery code during issue")? + .rows_affected(); + if spent != 1 { + let _ = tx.rollback().await; + return Ok(Err(IssueRefusal::RecoverySpent)); + } + } + // Opportunistic retention sweep (see the SQLite arm). + sqlx::query("DELETE FROM user_mfa_grants WHERE username = $1 AND expires_at < $2") + .bind(&username) + .bind(now) + .execute(&mut *tx) + .await + .context("sweep expired MFA grants")?; + sqlx::query( + "INSERT INTO audit_log (actor, action, target, diff_json, occurred_at) + VALUES ($1, $2, $3, NULL, $4)", + ) + .bind(actor) + .bind(audit_action) + .bind(&target) + .bind(now) + .execute(&mut *tx) + .await + .context("audit MFA proof")?; + tx.commit().await.context("commit MFA grant issue")?; + } + } + Ok(Ok(id)) +} + +/// 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<()> { + // Lock ORDER: user_mfa (epoch bump) before grants, matching issue() — + // opposite order deadlocks with a concurrent re-challenge on Postgres + // (codex review, #1005). The bump also stops an in-flight challenge + // that read the old epoch from issuing. + sqlx::query("UPDATE user_mfa SET security_epoch = security_epoch + 1 WHERE username = ?") + .bind(username) + .execute(&mut **tx) + .await + .context("bump MFA security epoch")?; + 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<()> { + // Lock ORDER (see delete_all_sqlite): user_mfa before grants. + sqlx::query("UPDATE user_mfa SET security_epoch = security_epoch + 1 WHERE username = $1") + .bind(username) + .execute(&mut **tx) + .await + .context("bump MFA security epoch")?; + 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..02500ccb 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,161 @@ 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; + }; + // Read order matters (codex review r5, #1005): the factor row is read + // AFTER the grant, so its security_epoch is at least as fresh as the + // grant's — a stale-epoch grant that slipped in behind a racing + // revocation can never match a pre-revocation cached factor. Without + // a device cookie there is no grant read, so the factor is fetched + // directly (one SELECT either way — this runs on the proxy hot path + // in slice 4). + let cookie_parts = cookies + .get(DEVICE_COOKIE) + .and_then(|cookie| device_cookie_parts(cookie.value()).map(|(id, token)| (id.to_string(), token.to_string()))); + let Some((id, token)) = cookie_parts else { + return match crate::db::mfa::fetch(db, username).await { + Ok(Some(row)) if row.confirmed_at.is_some() => MfaDecision::ChallengeRequired, + Ok(_) => MfaDecision::EnrollmentRequired, + Err(err) => { + tracing::warn!(error = ?err, %username, "MFA decision factor fetch failed"); + MfaDecision::ChallengeRequired + } + }; + }; + let (id, token) = (id.as_str(), token.as_str()); + 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; + } + }; + // AUTHORITATIVE factor read — after the grant, so this epoch is at + // least as fresh as the grant's (see the ordering note above). + 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 re-read 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 + // Epoch binding (codex review, #1005): a grant that slipped past a + // racing revocation on pg (READ COMMITTED lets the conditional + // INSERT…SELECT read the pre-revocation epoch) carries the OLD + // epoch — the live factor row has the bumped one, so the grant is + // simply never accepted. Read-time validation makes issuance-time + // races harmless. + || grant.security_epoch != factor.security_epoch + { + 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 +379,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..6aafa7ef 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, @@ -404,6 +473,7 @@ async fn confirm( session: AdminSession, State(state): State, cookies: Cookies, + headers: HeaderMap, loc: Locale, theme: Theme, Form(form): Form, @@ -502,14 +572,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 +590,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() { @@ -553,6 +642,58 @@ async fn confirm( } crate::mfa::CONFIRM_LIMITER.record_success(username); clear_ceremony_cookie(&state, &cookies); + + // The confirmation code just entered IS a valid MFA proof, so mint the + // browser's initial device grant now — otherwise the recovery page's + // "Continue to app" link is immediately bounced to a challenge for the + // same code, which the replay guard rejects until the next TOTP + // interval (codex review, #1005). The enrollment already consumed the + // step, so this issuance passes no step/recovery to consume; it records + // the device proof (audited mfa.verify — the device is verified, beside + // the mfa.enroll already written). Non-fatal: the recovery codes must + // still render even if issuance fails; worst case is one extra + // challenge. + if let Some(session_id) = cookies.get(COOKIE_NAME).map(|c| c.value().to_string()) { + if let Ok(Some(confirmed)) = db::mfa::fetch(db, username).await { + if let Some(factor_confirmed_at) = confirmed.confirmed_at { + if let (Ok(token), verified_at) = + (crate::mfa::generate_device_token(), chrono::Utc::now()) + { + if let Ok(token_hash) = crate::mfa::hash_device_token(&token) { + let expires_at = verified_at + + chrono::Duration::days(i64::from( + ruscker_config::MAX_MFA_VALIDITY_DAYS, + )); + match db::mfa_grants::issue( + db, + username, + &token_hash, + &crate::mfa::session_binding(&session_id), + factor_confirmed_at, + verified_at, + expires_at, + confirmed.security_epoch, + None, + None, + "mfa.verify", + username, + ) + .await + { + Ok(Ok(id)) => { + issue_device_cookie(&state, &cookies, &headers, &id, &token) + } + other => tracing::warn!( + %username, result = ?other, + "initial MFA grant after enrollment not issued; user will challenge once" + ), + } + } + } + } + } + } + let page = AccountMfaRecoveryPage { locale: loc, theme, @@ -570,3 +711,377 @@ 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, + ); + }; + // NOTE: no blanket master-key check here (codex review r6, #1005): + // only the TOTP branch decrypts the secret. Recovery codes compare + // salted hashes and must keep working on a node that restarted + // without RUSCKER_MASTER_KEY — they are exactly the break-in-case- + // of-emergency path. + 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, recovery_id, totp_step) = match form.kind.as_str() { + "totp" => { + if !state.master_key.is_configured() { + return key_missing(&state, loc); + } + 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, + ); + }; + // Step consumption happens INSIDE the issuance transaction + // (codex review r5): a refused issuance rolls it back so the + // current 30s code stays usable for the retry. + ("mfa.verify", None, Some(step)) + } + "recovery" => match db::mfa::find_recovery_candidate(db, username, form.code.trim()).await + { + // Consumption is deferred into the issue() transaction so a + // failed grant (stale epoch) rolls the spend back (codex + // review, #1005). + Ok(Some(id)) => ("mfa.recovery_used", Some(id), None), + Ok(None) => { + return render_challenge( + &state, + session.role, + loc, + theme, + false, + "wrong-code", + next, + StatusCode::UNAUTHORIZED, + ); + } + Err(err) => { + tracing::error!(error = ?err, %username, "match 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(); + }; + // Grant issuance UPSERTs the single row for this browser-session + // (keyed by session_binding), so rotation, stale-cookie-after-revocation + // and concurrent challenges all resolve to exactly one live grant — no + // previous-id juggling from the cookie needed (codex review, #1005). + let id = match db::mfa_grants::issue( + db, + username, + &token_hash, + &crate::mfa::session_binding(&session_id), + factor_confirmed_at, + verified_at, + expires_at, + row.security_epoch, + recovery_id.as_deref(), + totp_step, + audit_action, + username, + ) + .await + { + Ok(Ok(id)) => id, + Ok(Err(db::mfa_grants::IssueRefusal::StaleEpoch)) => { + // A revocation (password change, forget-all) landed while this + // challenge was in flight: the epoch moved, so no grant was + // issued — and a consumed recovery code was rolled back with + // it (codex review, #1005). The proof itself was valid — ask + // the user to challenge again under the new epoch. + tracing::info!(%username, "MFA grant refused: security epoch moved mid-challenge"); + return ( + StatusCode::CONFLICT, + "device trust was revoked during this verification — try again", + ) + .into_response(); + } + Ok(Err(db::mfa_grants::IssueRefusal::Replayed)) => { + return render_challenge( + &state, + session.role, + loc, + theme, + false, + "replayed", + next, + StatusCode::UNAUTHORIZED, + ); + } + Ok(Err(db::mfa_grants::IssueRefusal::RecoverySpent)) => { + // The matched recovery code raced another consumer between the + // lookup and the transactional spend — treat as a wrong code. + return render_challenge( + &state, + session.role, + loc, + theme, + false, + "wrong-code", + next, + StatusCode::UNAUTHORIZED, + ); + } + Err(err) => { + tracing::error!(error = ?err, %username, "issue MFA device grant failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "MFA grant 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/src/routes/proxy.rs b/crates/ruscker-admin/src/routes/proxy.rs index 4b096977..63a63884 100644 --- a/crates/ruscker-admin/src/routes/proxy.rs +++ b/crates/ruscker-admin/src/routes/proxy.rs @@ -1971,9 +1971,15 @@ const RUSCKER_COOKIE_NAMES: &[&str] = &[ /// Is `name` a cookie Ruscker owns (and must therefore never reach an /// upstream app)? Sticky cookies are per-spec since #731 /// (`__ruscker_session_{spec}`), so they match by prefix — which also -/// covers the legacy un-suffixed `__ruscker_session`. +/// covers the legacy un-suffixed `__ruscker_session`. The `__ruscker_mfa_` +/// prefix covers the trusted-device grant (#1005 — root-scoped so the +/// slice-4 guard sees it on /app/*, which means browsers send it here +/// too: it is an MFA BEARER and must never reach a container) and the +/// enrollment-ceremony cookie. fn is_ruscker_cookie(name: &str) -> bool { - RUSCKER_COOKIE_NAMES.contains(&name) || name.starts_with(COOKIE_NAME) + RUSCKER_COOKIE_NAMES.contains(&name) + || name.starts_with(COOKIE_NAME) + || name.starts_with("__ruscker_mfa_") } /// Drop Ruscker's own cookies from a raw `Cookie` header value, 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 != "" %}