feat(auth): B-2.5 TOTP MFA + recovery codes (Fernet-encrypted secret)

Adds TOTP-based two-factor authentication (RFC 6238) with 10 single-use
recovery codes. Secret is encrypted at rest with a Fernet key derived
deterministically from app SECRET_KEY (SHA-256 -> urlsafe-base64); the raw
base32 secret never lives in the database. Recovery codes are bcrypt-hashed
and consumed atomically (single-use, removed from the JSON list on match).

Routes:
- GET /2fa/setup: generate fresh secret + QR + 10 recovery codes; cache
  pending state in session, render auth/totp_setup.html with inline QR
  data URL and the 10 codes shown ONCE.
- POST /2fa/setup: verify the user-submitted 6-digit code against the
  pending secret; on success persist encrypted secret + hashes and flip
  totp_enabled=True. On invalid code re-render same QR (don't rotate),
  preserving the user's authenticator scan.
- GET /2fa/verify: second factor during login; reads pending_totp_user_id
  from session and renders auth/totp_verify.html (TOTP code input +
  collapsed recovery code form, with X codes restants notice).
- POST /2fa/verify: accepts EITHER a 6-digit TOTP code OR a recovery code;
  on success finalises login_user (preserving remember-me intent + next
  URL captured at the password step), audits success/failure.
- POST /2fa/disable: requires password re-auth; nullifies the 3 TOTP fields.

Login gate (src/api/auth.py /login): after password+email-verification
checks but BEFORE login_user, if user.totp_enabled set
session['pending_totp_user_id'] / pending_totp_remember /
pending_totp_next and 302 -> /2fa/verify. OAuth/SSO/magic-link paths are
intentionally NOT gated in B-2.5 (deferred — IdP handles its own MFA).

Schema:
- New JSON column User.totp_recovery_codes (nullable) added via
  add_column_if_not_exists in src/init_db.py (no Alembic, follows existing
  pattern).
- Re-uses B-2.1 columns totp_secret_encrypted (VARCHAR 255) and
  totp_enabled (BOOLEAN); both already migrated.

Compatibility audit overrides honoured:
- Service layer at src/auth/totp.py (NOT a new src/auth_extended/ pkg).
- Templates at templates/auth/totp_setup.html and templates/auth/totp_verify.html
  extending marketing/base.html with brand tokens + WCAG patterns
  (focus-visible, role=alert, aria-required, autocomplete=one-time-code,
  inputmode=numeric).
- account.html integration deferred to a polish task — admins access
  /2fa/setup directly for now.

Tests (21, all green via Windows manual driver):
- Service layer: encrypt/decrypt round-trip, key-mismatch rejection, secret
  validity, code verification (current/wrong/non-digit), recovery codes
  (10 pairs, 1:1 bcrypt mapping, single-use consumption, unknown rejection),
  set/disable user TOTP fields.
- Routes: login redirect-to-/2fa/verify when totp_enabled, direct login
  when disabled, /2fa/verify with correct/wrong TOTP, recovery code consume,
  redirect-to-login when no pending session, /2fa/setup GET creates pending,
  POST with valid code enables MFA, POST with invalid code keeps pending +
  returns 400, /2fa/disable wrong/correct password.

Regression check: prior 21 OAuth+magic-link, 16 email-service, and 9
signup-Loi-25 tests all still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Allison
2026-04-28 00:08:40 -04:00
parent 3a41bb482d
commit aa269c5bc0
9 changed files with 1208 additions and 0 deletions

View File

@@ -127,6 +127,7 @@
--tracking-tight: -0.025em;
--tracking-wide: 0.025em;
--tracking-wider: 0.05em;
--tracking-widest: 0.1em;
--leading-snug: 1.375;
--leading-relaxed: 1.625;
--radius-md: 0.375rem;
@@ -756,6 +757,9 @@
.h-32 {
height: calc(var(--spacing) * 32);
}
.h-48 {
height: calc(var(--spacing) * 48);
}
.h-64 {
height: calc(var(--spacing) * 64);
}
@@ -2358,6 +2362,10 @@
--tw-tracking: var(--tracking-wider);
letter-spacing: var(--tracking-wider);
}
.tracking-widest {
--tw-tracking: var(--tracking-widest);
letter-spacing: var(--tracking-widest);
}
.break-words {
overflow-wrap: break-word;
}
@@ -2436,6 +2444,12 @@
.text-amber-900 {
color: var(--color-amber-900);
}
.text-amber-900\/90 {
color: color-mix(in srgb, oklch(41.4% 0.112 45.904) 90%, transparent);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-amber-900) 90%, transparent);
}
}
.text-blue-400 {
color: var(--color-blue-400);
}
@@ -2454,6 +2468,9 @@
.text-blue-900 {
color: var(--color-blue-900);
}
.text-brand-b1 {
color: #0062ff;
}
.text-brand-b3 {
color: #00c896;
}
@@ -3255,6 +3272,13 @@
}
}
}
.hover\:bg-brand-navy2 {
&:hover {
@media (hover: hover) {
background-color: #0b1525;
}
}
}
.hover\:bg-emerald-700 {
&:hover {
@media (hover: hover) {
@@ -3541,6 +3565,13 @@
}
}
}
.hover\:text-brand-b1 {
&:hover {
@media (hover: hover) {
color: #0062ff;
}
}
}
.hover\:text-brand-navy {
&:hover {
@media (hover: hover) {
@@ -4163,6 +4194,11 @@
min-height: 12rem;
}
}
.md\:w-48 {
@media (width >= 48rem) {
width: calc(var(--spacing) * 48);
}
}
.md\:grid-cols-2 {
@media (width >= 48rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));