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>
Targeted fixes for issues raised by code review on commit 37639a7
(B-2.3 DictIA email rebrand). All fixes verified against the Windows
manual driver: 16/16 tests pass (12 pre-existing + 4 new regression).
Critical:
- C1 Stored XSS in transactional emails: user.name (validated only on
Length(max=49), no character class) was rendered raw into the f-string
HTML body of verification + reset emails. Added html.escape on the
HTML branch; text body keeps the raw string (no XSS surface). Also
hardened the fallback chain to ((name or '').strip() or username or
'utilisateur').strip() so a None/whitespace name never produces
'Bonjour ,'.
- C2 Reflected XSS in templates/auth/check_email.html: the email value
from request.form was concatenated with literal '<strong>' tags then
fed through | safe, defeating Jinja's autoescape. Split the string so
template-author HTML stays literal and {{ email }} is autoescaped.
Used   for NBSP instead of '1 heure' | safe (more readable).
Important:
- I1 Dropped {{ message | safe }} on flash blocks in
forgot_password.html and reset_password.html (matches check_email.html).
No XSS today (flashes are static literals) but removes the landmine.
- I2 Password reset token replay: URLSafeTimedSerializer is stateless,
so the same valid link could be clicked twice within the 1h window.
Added a check that user.password_reset_token == token after the user
lookup — runs before BOTH GET (form render) and POST (password update).
The existing 'user.password_reset_token = None' on success now
actually invalidates the token.
- I5 MIMEText defaults to us-ascii, which Q-encodes accented French
characters and produces mojibake in some clients. Added explicit
'utf-8' charset on both text and html parts in _send_email.
New regression tests (tests/test_email_service_dictia.py):
- test_verification_email_falls_back_when_name_is_whitespace (I4)
- test_verification_email_handles_unicode_name (I5)
- test_verification_email_escapes_html_in_user_name (C1)
- test_check_email_template_escapes_email_in_response (C2)
Out of scope (per review): M1 (already addressed via solid-color
fallback), M2 (datetime.utcnow — pre-existing, separate cleanup),
M3 (Windows test driver — documented in tests file docstring),
M4-M6 (deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rebrand src/services/email.py IN PLACE: French + DictIA + brand gradient
(#0062ff/#00bdd8/#00c896) — replaces legacy "Speakr" / #2563eb. Greetings now
use user.name with fallback to user.username. Subjects:
"Vérifiez votre courriel — DictIA" + "Réinitialiser votre mot de passe — DictIA".
SMTP_FROM_NAME defaults to DictIA. Footer points to info@dictia.ca with the
Loi 25 tagline.
Refonte 4 auth templates IN PLACE pour étendre marketing/base.html : check_email,
forgot_password, reset_password, verify_success. Tokens DictIA (brand-navy,
brand-bg, grad-bg, shadow-cta), French copy, WCAG patterns (label for,
focus-visible:outline-2, role=alert, aria-required, text-brand-navy/70 minimum,
NBSP français pour Loi 25 / 24 heures / 1 heure / 8 caractères).
Translate inline French flash messages in src/api/auth.py for /verify-email,
/resend-verification, /forgot-password, /reset-password. Anti-enumeration fix:
forgot_password no longer flashes the cooldown remaining (would leak account
existence) — silently skips resend, generic flash unchanged. Cooldown logic
in src/services/email.py UNCHANGED (60s — verified by test).
config/env.email.example: defaults to Resend SMTP at the top + adds Resend
to the provider examples list (preserves Gmail/SendGrid/Mailgun/SES/M365).
Tests: tests/test_email_service_dictia.py — 12 tests covering DictIA branding,
French copy, display-name fallback, anti-enumeration parity (forgot_password
returns identical message for known/unknown emails), 60s cooldown, SMTP-not-
configured returns False (no exception), check_email.html extends marketing/base
(no var(--text-primary) leaks). Includes Windows manual driver
(_run_email_service_dictia_windows.py) since pytest cannot collect on Windows
native (fcntl POSIX-only).
NO new dependency added (no resend SDK — SMTP via existing _send_email).
NO new route added or removed.
NO src/auth_extended/ created.
NO change to itsdangerous-based token logic.
templates/auth/**/*.html already in tailwind.config.js content array (B-2.2).
Verified locally on Windows manual driver: 12/12 PASS B-2.3, 9/9 PASS regression
on B-2.2 signup, 9/9 PASS regression on B-2.1 ConsentLog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>