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:
93
templates/auth/totp_setup.html
Normal file
93
templates/auth/totp_setup.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% extends 'marketing/base.html' %}
|
||||
|
||||
{% block title %}Configurer la double authentification — DictIA{% endblock %}
|
||||
{% block description %}Activez la double authentification (TOTP) sur votre compte DictIA pour protéger vos données conformément aux exigences Loi 25.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="totp-setup-title">
|
||||
<div class="max-w-2xl mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
|
||||
<h1 id="totp-setup-title" class="text-3xl font-black text-brand-navy mb-2">Configurer la double authentification</h1>
|
||||
<p class="text-sm text-brand-navy/70 mb-6">{{ "La double authentification (2FA) ajoute une seconde étape lors de la connexion, en plus de votre mot de passe. Une exigence forte recommandée pour les comptes traitant des données confidentielles (Loi 25)." | safe }}</p>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div role="alert" class="mb-3 p-3 rounded-lg text-sm
|
||||
{% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
|
||||
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
|
||||
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
|
||||
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if error %}
|
||||
<div role="alert" class="mb-4 p-3 rounded-lg text-sm bg-red-50 text-red-900 border border-red-200">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<ol class="space-y-6">
|
||||
<li>
|
||||
<h2 class="text-lg font-bold text-brand-navy mb-2"><span class="grad-text">1.</span> Installez une application d'authentification</h2>
|
||||
<p class="text-sm text-brand-navy/80">Sur votre téléphone, installez par exemple <strong>Google Authenticator</strong>, <strong>Microsoft Authenticator</strong>, <strong>Authy</strong> ou <strong>1Password</strong>.</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2 class="text-lg font-bold text-brand-navy mb-2"><span class="grad-text">2.</span> Scannez le code QR</h2>
|
||||
<div class="flex flex-col md:flex-row gap-6 items-start">
|
||||
<div class="bg-brand-bg border border-brand-border rounded-[0.75rem] p-4 flex-shrink-0">
|
||||
<img src="{{ qr_data_url }}" alt="Code QR pour configurer DictIA dans votre application authenticator" class="w-48 h-48 mx-auto block">
|
||||
</div>
|
||||
<div class="text-sm text-brand-navy/80 space-y-2">
|
||||
<p>Pointez l'appareil photo de votre application authenticator vers ce code QR.</p>
|
||||
<p class="text-xs text-brand-navy/60">Vous ne pouvez pas scanner ?<br>Saisissez la clé manuellement :</p>
|
||||
<code class="block bg-brand-bg border border-brand-border rounded px-3 py-2 text-xs font-mono text-brand-navy break-all select-all">{{ secret }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2 class="text-lg font-bold text-brand-navy mb-2"><span class="grad-text">3.</span> Conservez vos codes de récupération</h2>
|
||||
<div role="alert" class="bg-amber-50 border border-amber-200 rounded-[0.75rem] p-4 mb-3">
|
||||
<p class="text-sm font-semibold text-amber-900 mb-2">Important — ces codes ne seront affichés qu'une seule fois.</p>
|
||||
<p class="text-xs text-amber-900/90">Imprimez-les ou enregistrez-les dans votre gestionnaire de mots de passe. Chaque code est à usage unique et permettra de vous reconnecter si vous perdez l'accès à votre application authenticator.</p>
|
||||
</div>
|
||||
<pre id="recovery-codes" class="bg-brand-navy text-white text-sm font-mono p-4 rounded-[0.75rem] whitespace-pre-wrap select-all">{% for c in recovery_codes %}{{ c }}
|
||||
{% endfor %}</pre>
|
||||
<button type="button" onclick="(function(){var t=document.getElementById('recovery-codes').innerText;if(navigator.clipboard){navigator.clipboard.writeText(t);}var b=document.getElementById('copy-btn');b.textContent='Copié dans le presse-papiers';setTimeout(function(){b.textContent='Copier les codes';},2000);})();"
|
||||
id="copy-btn"
|
||||
class="mt-2 inline-flex items-center gap-2 text-xs font-semibold text-brand-b1 hover:underline focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||
Copier les codes
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2 class="text-lg font-bold text-brand-navy mb-2"><span class="grad-text">4.</span> Confirmez avec un code à 6 chiffres</h2>
|
||||
<p class="text-sm text-brand-navy/70 mb-4">Entrez le code à 6 chiffres affiché actuellement dans votre application authenticator pour valider l'installation.</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.totp_setup') }}" class="space-y-4" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-brand-navy mb-1">Code à 6 chiffres <span class="text-red-600" aria-hidden="true">*</span></label>
|
||||
<input type="text" id="code" name="code" required aria-required="true"
|
||||
inputmode="numeric" autocomplete="one-time-code"
|
||||
pattern="[0-9]{6}" maxlength="6"
|
||||
class="w-full md:w-48 px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy text-center text-xl font-mono tracking-widest focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
|
||||
placeholder="000000" autofocus>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="grad-bg text-white font-semibold py-3 px-6 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||
Activer la double authentification
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p class="text-center text-sm text-brand-navy/70 mt-8 pt-6 border-t border-brand-border">
|
||||
<a href="{{ url_for('auth.account') }}" class="grad-text font-semibold">← Annuler et retourner au compte</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user