import secrets from werkzeug.security import check_password_hash, generate_password_hash from .connection import connect _CODE_COUNT = 10 def _format_code(raw: str) -> str: return "-".join(raw[i : i + 4] for i in range(0, len(raw), 4)) def generate_recovery_codes(user_id: int) -> list[str]: """(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: list[str] = [] 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: int, code: str | None) -> bool: """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