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>
61 lines
2.0 KiB
Python
61 lines
2.0 KiB
Python
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
|