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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user