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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user