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>
- ConsentLog.user_id: nullable=True + ondelete='SET NULL' for Loi 25 art. 28.1
right-to-erasure (audit row survives user deletion, user_id nulled out).
Matches existing pattern in auth_log.py / access_log.py.
- Add ConsentLog.@validates('consent_type') to reject typos at ORM level
(silent typos in audit data are very hard to detect later).
- Rename User.totp_secret -> totp_secret_encrypted (size 64->255 for Fernet
envelope). Self-documenting contract: never assign plaintext to this column.
- init_db.py: drop NOT NULL from totp_enabled migration string for consistency
with every other Boolean column in the file (model-side nullable=False is
sufficient).
- Docs: User class docstring updated to reflect MFA/billing/ordre context;
webauthn_credentials shape documented; version column policy documented.
- Tests: cleaner IntegrityError catch; add survives_user_deletion test
(right-to-erasure); add rejects_invalid_consent_type test (validator).