Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions crates/ruscker-admin/assets/i18n/en/landing.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions crates/ruscker-admin/assets/i18n/es/landing.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions crates/ruscker-admin/assets/i18n/fr/landing.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions crates/ruscker-admin/assets/i18n/pt/landing.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions crates/ruscker-admin/migrations-pg/0030_user_mfa_grants.sql
Original file line number Diff line number Diff line change
@@ -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;
29 changes: 29 additions & 0 deletions crates/ruscker-admin/migrations/0030_user_mfa_grants.sql
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions crates/ruscker-admin/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
117 changes: 115 additions & 2 deletions crates/ruscker-admin/src/db/mfa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ pub struct MfaRow {
pub updated_at: DateTime<Utc>,
/// Reserved for slice 3's accepted-step replay prevention.
pub last_used_step: Option<i64>,
/// 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 = (
Expand All @@ -35,6 +38,7 @@ type StoredRow = (
DateTime<Utc>,
DateTime<Utc>,
Option<i64>,
i64,
);

/// Start or replace a pending enrollment. A confirmed factor is never
Expand Down Expand Up @@ -239,7 +243,7 @@ pub async fn fetch(db: &ConfigDb, username: &str) -> Result<Option<MfaRow>> {
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)
Expand All @@ -249,7 +253,7 @@ pub async fn fetch(db: &ConfigDb, username: &str) -> Result<Option<MfaRow>> {
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)
Expand All @@ -268,6 +272,7 @@ pub async fn fetch(db: &ConfigDb, username: &str) -> Result<Option<MfaRow>> {
created_at,
updated_at,
last_used_step,
security_epoch,
)| {
MfaRow {
username,
Expand All @@ -278,6 +283,7 @@ pub async fn fetch(db: &ConfigDb, username: &str) -> Result<Option<MfaRow>> {
created_at,
updated_at,
last_used_step,
security_epoch,
}
},
))
Expand Down Expand Up @@ -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<Option<String>> {
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<bool> {
Expand Down Expand Up @@ -440,6 +483,37 @@ pub async fn is_enrolled(db: &ConfigDb, username: &str) -> Result<bool> {
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<bool> {
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<()> {
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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"))
Expand Down
Loading