security: MFA backup codes — issue on enable, redeem at login, regenerate on demand (#43)
CI / Architecture Guardrails (push) Successful in 6m1s
CI / Assistant Split Regression (push) Successful in 6m52s
CI / Lint (push) Successful in 8m40s
CI / Typecheck (push) Successful in 9m45s
CI / Unit Tests (push) Successful in 7m28s
CI / Build (push) Failing after 10m16s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Architecture Guardrails (push) Successful in 6m1s
CI / Assistant Split Regression (push) Successful in 6m52s
CI / Lint (push) Successful in 8m40s
CI / Typecheck (push) Successful in 9m45s
CI / Unit Tests (push) Successful in 7m28s
CI / Build (push) Failing after 10m16s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
Adds a one-time-use backup code set so users with a lost authenticator are not locked out. Codes are Crockford base32 (XXXXX-XXXXX), hashed with argon2id, and redeemed under a WHERE-guarded delete so a concurrent replay race fails closed. - New MfaBackupCode model + migration - Issue 10 codes inside the enable transaction; show plaintext exactly once - Sign-in page accepts TOTP or backup code, reporting remaining count - regenerateBackupCodes tRPC mutation wipes + reissues atomically - Unit coverage for generator, normalizer, verify, redeem, and race path Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -205,6 +205,7 @@ model User {
|
||||
activeSessions ActiveSession[]
|
||||
reportTemplates ReportTemplate[]
|
||||
assistantApprovals AssistantApproval[]
|
||||
mfaBackupCodes MfaBackupCode[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -212,6 +213,24 @@ model User {
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// One row per still-redeemable backup code. We store argon2id(code) — never
|
||||
// the plaintext — and delete the row on redemption so replay is physically
|
||||
// impossible. Generation wipes and recreates the whole set (kick-oldest
|
||||
// strategy not used here: recovery codes are all-or-nothing, a partial
|
||||
// set is worse than none).
|
||||
model MfaBackupCode {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
codeHash String
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("mfa_backup_codes")
|
||||
}
|
||||
|
||||
enum AssistantApprovalStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
|
||||
Reference in New Issue
Block a user