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:
william
2026-08-28 20:42:39 +02:00
co-authored by Claude Sonnet 5
parent a52b244f27
commit 9efe119936
12 changed files with 181 additions and 4 deletions
+1
View File
@@ -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 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 recovery_codes_flash # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja
import auth
import db
+44
View File
@@ -263,6 +263,50 @@ def test_successful_login_resets_the_failed_attempts_counter(anon_client):
_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):
"""`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