Ajoute des codes de récupération 2FA (perte du téléphone)
10 codes à usage unique (format "xxxx-xxxx-xxxx") sont générés à
l'instant même où la 2FA est confirmée (auth/recovery_codes.py, table
_recovery_codes séparée pour marquer/consommer chaque code un par un) —
seul leur hash (werkzeug, comme les mots de passe) est stocké, ils ne
sont visibles en clair qu'à cet instant précis.
Plutôt que d'interrompre la redirection habituelle après confirmation de
la 2FA, les codes sont posés en session ("recovery_codes_to_show") et
affichés une seule fois, en modal, dès le premier rendu de base.html qui
suit (core/recovery_codes_flash.py, session.pop) — préserve tel quel le
comportement de redirection déjà couvert par les tests existants.
Sur /login/2fa, un code de récupération est accepté à la place du code
TOTP habituel (routes/auth/login_2fa.py) : verify_totp est essayé en
premier (verify_recovery_code consomme le code dès qu'il correspond, on
ne veut pas en griller un pour rien sur une saisie qui aurait en fait
été un TOTP valide). Compte toujours vers le même compteur anti-bruteforce
que le code TOTP (déjà en place, voir auth/rate_limit.py).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a52b244f27
commit
9efe119936
@@ -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 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 # 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 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():
|
def _open_browser():
|
||||||
|
|||||||
@@ -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 .rate_limit import lockout_minutes_for, seconds_locked_remaining, lockout_message
|
||||||
from .record_failed_attempt import record_failed_attempt
|
from .record_failed_attempt import record_failed_attempt
|
||||||
from .reset_failed_attempts import reset_failed_attempts
|
from .reset_failed_attempts import reset_failed_attempts
|
||||||
|
from .recovery_codes import generate_recovery_codes, verify_recovery_code
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"users_db_path", "ensure_users_schema", "is_first_user",
|
"users_db_path", "ensure_users_schema", "is_first_user",
|
||||||
@@ -29,4 +30,5 @@ __all__ = [
|
|||||||
"totp_provisioning_uri", "totp_qrcode_svg",
|
"totp_provisioning_uri", "totp_qrcode_svg",
|
||||||
"lockout_minutes_for", "seconds_locked_remaining", "lockout_message",
|
"lockout_minutes_for", "seconds_locked_remaining", "lockout_message",
|
||||||
"record_failed_attempt", "reset_failed_attempts",
|
"record_failed_attempt", "reset_failed_attempts",
|
||||||
|
"generate_recovery_codes", "verify_recovery_code",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -29,5 +29,20 @@ def ensure_users_schema():
|
|||||||
conn.execute("ALTER TABLE _users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0")
|
conn.execute("ALTER TABLE _users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0")
|
||||||
if "locked_until" not in cols:
|
if "locked_until" not in cols:
|
||||||
conn.execute("ALTER TABLE _users ADD COLUMN locked_until TEXT")
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -18,9 +18,19 @@ def login_2fa():
|
|||||||
error = None
|
error = None
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
locked_seconds = auth.seconds_locked_remaining(user)
|
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:
|
if locked_seconds:
|
||||||
error = auth.lockout_message(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)
|
just_locked_minutes = auth.record_failed_attempt(user_id)
|
||||||
error = auth.lockout_message(just_locked_minutes * 60) if just_locked_minutes else "Code invalide."
|
error = auth.lockout_message(just_locked_minutes * 60) if just_locked_minutes else "Code invalide."
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ def register_2fa():
|
|||||||
user = auth.get_user_by_id(user_id)
|
user = auth.get_user_by_id(user_id)
|
||||||
session.pop("pending_register_user_id", None)
|
session.pop("pending_register_user_id", None)
|
||||||
session["user_id"] = user_id
|
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":
|
if user["role"] == "admin":
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
|
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
|
||||||
|
|||||||
@@ -588,3 +588,6 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
|
|||||||
/* ---------- Inscription : QR code de la double authentification ---------- */
|
/* ---------- Inscription : QR code de la double authentification ---------- */
|
||||||
.totpQrWrap{ background:#fff; padding:16px; border-radius:8px; display:inline-block; max-width:220px; }
|
.totpQrWrap{ background:#fff; padding:16px; border-radius:8px; display:inline-block; max-width:220px; }
|
||||||
.totpQrWrap svg{ display:block; width:100%; height:auto; }
|
.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; }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container" style="max-width:420px;">
|
<div class="container" style="max-width:420px;">
|
||||||
<h1 class="title">Code de vérification</h1>
|
<h1 class="title">Code de vérification</h1>
|
||||||
<p class="hint mb-4">Ouvre ton application d'authentification et saisis le code à 6 chiffres affiché.</p>
|
<p class="hint mb-4">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.</p>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="notification is-danger">{{ error }}</div>
|
<div class="notification is-danger">{{ error }}</div>
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
|
|
||||||
<form method="post" action="{{ url_for('login_2fa') }}">
|
<form method="post" action="{{ url_for('login_2fa') }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Code à 6 chiffres</label>
|
<label class="label">Code à 6 chiffres ou code de récupération</label>
|
||||||
<div class="control"><input class="input" type="text" name="code" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required autofocus></div>
|
<div class="control"><input class="input" type="text" name="code" autocomplete="one-time-code" required autofocus></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field mt-4">
|
<div class="field mt-4">
|
||||||
<button type="submit" class="button primary is-fullwidth">Se connecter</button>
|
<button type="submit" class="button primary is-fullwidth">Se connecter</button>
|
||||||
|
|||||||
@@ -22,6 +22,24 @@
|
|||||||
ne rechargeait pas la page en entier (Ctrl+F5), et inversement
|
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
|
laissée en place en revenant sur l'accueil (liste des jeux) où elle
|
||||||
n'a pourtant rien à faire. -->
|
n'a pourtant rien à faire. -->
|
||||||
|
{% set recovery_codes = pop_recovery_codes() %}
|
||||||
|
{% if recovery_codes %}
|
||||||
|
<div class="modal is-active" id="recoveryCodesModal">
|
||||||
|
<div class="modal-background"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="title is-4">Tes codes de récupération</h2>
|
||||||
|
<p class="hint mb-4">Note ces 10 codes en sécurité (gestionnaire de mots de passe, papier...) : chacun ne fonctionne qu'une seule fois et permet de te connecter si tu perds l'accès à ton application d'authentification. Ils ne seront plus jamais affichés.</p>
|
||||||
|
<div class="recoveryCodesGrid">
|
||||||
|
{% for code in recovery_codes %}
|
||||||
|
<code class="recoveryCode">{{ code }}</code>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="button primary is-fullwidth mt-4" onclick="document.getElementById('recoveryCodesModal').remove()">J'ai noté mes codes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div id="pageChrome">
|
<div id="pageChrome">
|
||||||
<nav class="navbar topbar" role="navigation" aria-label="main navigation">
|
<nav class="navbar topbar" role="navigation" aria-label="main navigation">
|
||||||
<div class="navbar-brand">
|
<div class="navbar-brand">
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import routes # noqa: F401 - enregistre toutes les routes sur flask_app
|
|||||||
from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes)
|
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 # noqa: F401 - enregistre csrf_token() comme variable globale Jinja
|
||||||
from core import csrf_guard # noqa: F401 - enregistre la vérification du jeton CSRF (désactivée en TESTING, voir test_csrf.py)
|
from core import csrf_guard # noqa: F401 - enregistre la vérification du jeton CSRF (désactivée en TESTING, voir test_csrf.py)
|
||||||
|
from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja
|
||||||
import auth
|
import auth
|
||||||
import db
|
import db
|
||||||
|
|
||||||
|
|||||||
@@ -263,6 +263,50 @@ def test_successful_login_resets_the_failed_attempts_counter(anon_client):
|
|||||||
_cleanup_project("resetcounter@example.com")
|
_cleanup_project("resetcounter@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_codes_are_shown_once_after_confirming_2fa(anon_client):
|
||||||
|
_register(anon_client, "recoveryshown@example.com")
|
||||||
|
resp, secret = _confirm_2fa(anon_client)
|
||||||
|
try:
|
||||||
|
html = anon_client.get(resp.headers["Location"]).get_data(as_text=True)
|
||||||
|
assert "recoveryCodesModal" in html
|
||||||
|
|
||||||
|
# Jamais réaffichés sur un chargement suivant (jeton flash à usage
|
||||||
|
# unique, voir core/recovery_codes_flash.py).
|
||||||
|
html_again = anon_client.get(resp.headers["Location"]).get_data(as_text=True)
|
||||||
|
assert "recoveryCodesModal" not in html_again
|
||||||
|
finally:
|
||||||
|
_cleanup_project("recoveryshown@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_with_a_recovery_code_instead_of_totp(anon_client):
|
||||||
|
_register(anon_client, "recoverylogin@example.com")
|
||||||
|
_confirm_2fa(anon_client)
|
||||||
|
user = auth.get_user_by_email("recoverylogin@example.com")
|
||||||
|
slug = user["project_slug"]
|
||||||
|
codes = auth.generate_recovery_codes(user["id"])
|
||||||
|
try:
|
||||||
|
anon_client.post("/logout")
|
||||||
|
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
|
||||||
|
|
||||||
|
resp = anon_client.post("/login/2fa", data={"code": codes[0]}, follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert resp.headers["Location"] == "/game/" + slug
|
||||||
|
|
||||||
|
# Un code déjà utilisé ne fonctionne plus une seconde fois.
|
||||||
|
anon_client.post("/logout")
|
||||||
|
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
|
||||||
|
resp = anon_client.post("/login/2fa", data={"code": codes[0]})
|
||||||
|
assert "Code invalide" in resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
# Un autre code du même lot, lui, fonctionne toujours.
|
||||||
|
anon_client.post("/logout")
|
||||||
|
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
|
||||||
|
resp = anon_client.post("/login/2fa", data={"code": codes[1]}, follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
finally:
|
||||||
|
_cleanup_project("recoverylogin@example.com")
|
||||||
|
|
||||||
|
|
||||||
def test_admin_role_is_not_restricted_to_a_single_project(client, game):
|
def test_admin_role_is_not_restricted_to_a_single_project(client, game):
|
||||||
"""`client`/`game` (conftest.py) sont déjà connectés en tant qu'admin
|
"""`client`/`game` (conftest.py) sont déjà connectés en tant qu'admin
|
||||||
de la session de tests — la création d'un second jeu, déjà exercée par
|
de la session de tests — la création d'un second jeu, déjà exercée par
|
||||||
|
|||||||
Reference in New Issue
Block a user