diff --git a/app.py b/app.py index 4f9d72d3..d827796d 100644 --- a/app.py +++ b/app.py @@ -24,6 +24,7 @@ import routes # noqa: F401 - enregistre toutes les routes sur `app` from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes) from core import csrf # noqa: F401 - enregistre csrf_token() comme variable globale Jinja from core import csrf_guard # noqa: F401 - enregistre la vérification du jeton CSRF +from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja def _open_browser(): diff --git a/auth/__init__.py b/auth/__init__.py index c25a4644..f4c33669 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -19,6 +19,7 @@ from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg from .rate_limit import lockout_minutes_for, seconds_locked_remaining, lockout_message from .record_failed_attempt import record_failed_attempt from .reset_failed_attempts import reset_failed_attempts +from .recovery_codes import generate_recovery_codes, verify_recovery_code __all__ = [ "users_db_path", "ensure_users_schema", "is_first_user", @@ -29,4 +30,5 @@ __all__ = [ "totp_provisioning_uri", "totp_qrcode_svg", "lockout_minutes_for", "seconds_locked_remaining", "lockout_message", "record_failed_attempt", "reset_failed_attempts", + "generate_recovery_codes", "verify_recovery_code", ] diff --git a/auth/ensure_schema.py b/auth/ensure_schema.py index b1f3cef0..035dba0d 100644 --- a/auth/ensure_schema.py +++ b/auth/ensure_schema.py @@ -29,5 +29,20 @@ def ensure_users_schema(): conn.execute("ALTER TABLE _users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0") if "locked_until" not in cols: conn.execute("ALTER TABLE _users ADD COLUMN locked_until TEXT") + # Codes de récupération 2FA (voir recovery_codes.py) : table séparée + # plutôt qu'une colonne JSON sur _users, pour pouvoir marquer/consommer + # chaque code UN PAR UN (used_at) sans avoir à relire/réécrire toute + # la liste à chaque connexion. + conn.execute( + """ + CREATE TABLE IF NOT EXISTS _recovery_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + code_hash TEXT NOT NULL, + used_at TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """ + ) conn.commit() conn.close() diff --git a/auth/recovery_codes.py b/auth/recovery_codes.py new file mode 100644 index 00000000..7c6b8799 --- /dev/null +++ b/auth/recovery_codes.py @@ -0,0 +1,60 @@ +import secrets + +from werkzeug.security import check_password_hash, generate_password_hash + +from .connection import connect + +_CODE_COUNT = 10 + + +def _format_code(raw): + return "-".join(raw[i:i + 4] for i in range(0, len(raw), 4)) + + +def generate_recovery_codes(user_id): + """(Re)génère les codes de récupération 2FA d'un utilisateur : un + nouvel appel invalide tout jeu de codes précédent (un seul jeu valide + à la fois, pour ne jamais avoir à deviner lesquels tiennent encore). + Les codes ne sont renvoyés EN CLAIR qu'ICI, à l'instant de leur + création — seul leur hash (werkzeug, comme les mots de passe) est + conservé en base, pour qu'une fuite de la base ne les rende pas + directement réutilisables.""" + conn = connect() + conn.execute("DELETE FROM _recovery_codes WHERE user_id = ?", (user_id,)) + codes = [] + for _ in range(_CODE_COUNT): + code = _format_code(secrets.token_hex(6)) + codes.append(code) + conn.execute( + "INSERT INTO _recovery_codes (user_id, code_hash) VALUES (?, ?)", + (user_id, generate_password_hash(code)), + ) + conn.commit() + conn.close() + return codes + + +def verify_recovery_code(user_id, code): + """Un code n'est utilisable qu'UNE SEULE FOIS (used_at) : consommé dès + qu'il sert à une connexion réussie, pour qu'un code intercepté une + fois (capture d'écran, historique du navigateur...) ne redonne pas un + accès permanent.""" + code = (code or "").strip().lower() + if not code: + return False + conn = connect() + rows = conn.execute( + "SELECT id, code_hash FROM _recovery_codes WHERE user_id = ? AND used_at IS NULL", + (user_id,), + ).fetchall() + for row in rows: + if check_password_hash(row["code_hash"], code): + conn.execute( + "UPDATE _recovery_codes SET used_at = CURRENT_TIMESTAMP WHERE id = ?", + (row["id"],), + ) + conn.commit() + conn.close() + return True + conn.close() + return False diff --git a/core/recovery_codes_flash.py b/core/recovery_codes_flash.py new file mode 100644 index 00000000..02e435ca --- /dev/null +++ b/core/recovery_codes_flash.py @@ -0,0 +1,15 @@ +"""Affiche UNE SEULE FOIS les codes de récupération 2FA (voir +auth/recovery_codes.py et routes/auth/register_2fa.py) : posés en session +au moment de la redirection qui suit leur génération, puis consommés +(session.pop) dès le tout premier rendu de base.html qui suit — jamais +revus après ce rendu, comme un message flash à usage unique.""" +from flask import session + +from .flask_app import app + + +def pop_recovery_codes(): + return session.pop("recovery_codes_to_show", None) + + +app.jinja_env.globals["pop_recovery_codes"] = pop_recovery_codes diff --git a/routes/auth/login_2fa.py b/routes/auth/login_2fa.py index f994e1be..c5006a9b 100644 --- a/routes/auth/login_2fa.py +++ b/routes/auth/login_2fa.py @@ -18,9 +18,19 @@ def login_2fa(): error = None if request.method == "POST": locked_seconds = auth.seconds_locked_remaining(user) + # Un code de récupération (format "xxxx-xxxx-xxxx") est accepté à la + # place du code TOTP habituel — utile si le téléphone est perdu/hors + # service (voir auth/recovery_codes.py). verify_totp est essayé en + # premier : verify_recovery_code CONSOMME le code dès qu'il + # correspond, on ne veut donc pas en griller un pour rien sur une + # saisie qui aurait en fait été un TOTP valide. + code = request.form.get("code", "") + valid = not locked_seconds and ( + auth.verify_totp(user["totp_secret"], code) or auth.verify_recovery_code(user_id, code) + ) if locked_seconds: error = auth.lockout_message(locked_seconds) - elif not auth.verify_totp(user["totp_secret"], request.form.get("code", "")): + elif not valid: just_locked_minutes = auth.record_failed_attempt(user_id) error = auth.lockout_message(just_locked_minutes * 60) if just_locked_minutes else "Code invalide." else: diff --git a/routes/auth/register_2fa.py b/routes/auth/register_2fa.py index d6717519..9c2d32ef 100644 --- a/routes/auth/register_2fa.py +++ b/routes/auth/register_2fa.py @@ -40,6 +40,14 @@ def register_2fa(): user = auth.get_user_by_id(user_id) session.pop("pending_register_user_id", None) session["user_id"] = user_id + # Générés ici, une seule fois, à l'instant même où la 2FA + # devient active (voir auth/recovery_codes.py) : c'est la SEULE + # occasion où ils sont visibles en clair (seul leur hash reste + # en base) — posés en session pour être affichés une unique + # fois par la page suivante (voir core/recovery_codes_flash.py + # et le modal dans base.html), plutôt que d'interrompre ici la + # redirection habituelle vers le tableau de bord/l'accueil. + session["recovery_codes_to_show"] = auth.generate_recovery_codes(user_id) if user["role"] == "admin": return redirect(url_for("index")) return redirect(url_for("game_dashboard", slug=user["project_slug"])) diff --git a/static/style.css b/static/style.css index b64099d4..a46f149e 100644 --- a/static/style.css +++ b/static/style.css @@ -588,3 +588,6 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p /* ---------- Inscription : QR code de la double authentification ---------- */ .totpQrWrap{ background:#fff; padding:16px; border-radius:8px; display:inline-block; max-width:220px; } .totpQrWrap svg{ display:block; width:100%; height:auto; } + +.recoveryCodesGrid{ display:grid; grid-template-columns:1fr 1fr; gap:8px; } +.recoveryCode{ display:block; text-align:center; padding:8px; background:#161616; border-radius:6px; letter-spacing:1px; } diff --git a/templates/auth/login_2fa.html b/templates/auth/login_2fa.html index a5b37cb1..fa6a8e82 100644 --- a/templates/auth/login_2fa.html +++ b/templates/auth/login_2fa.html @@ -4,7 +4,7 @@

Code de vérification

-

Ouvre ton application d'authentification et saisis le code à 6 chiffres affiché.

+

Ouvre ton application d'authentification et saisis le code à 6 chiffres affiché, ou un de tes codes de récupération si tu n'y as plus accès.

{% if error %}
{{ error }}
@@ -12,8 +12,8 @@
- -
+ +
diff --git a/templates/base.html b/templates/base.html index 4a480b32..cfbd9168 100644 --- a/templates/base.html +++ b/templates/base.html @@ -22,6 +22,24 @@ ne rechargeait pas la page en entier (Ctrl+F5), et inversement laissée en place en revenant sur l'accueil (liste des jeux) où elle n'a pourtant rien à faire. --> +{% set recovery_codes = pop_recovery_codes() %} +{% if recovery_codes %} + +{% endif %}