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>
57 lines
2.7 KiB
Python
57 lines
2.7 KiB
Python
from flask import redirect, render_template, request, session, url_for
|
|
|
|
import auth
|
|
|
|
from core.flask_app import app
|
|
|
|
|
|
@app.route("/login/2fa", methods=["GET", "POST"])
|
|
def login_2fa():
|
|
user_id = session.get("pending_login_user_id")
|
|
if not user_id:
|
|
return redirect(url_for("login"))
|
|
user = auth.get_user_by_id(user_id)
|
|
if not user:
|
|
session.pop("pending_login_user_id", None)
|
|
return redirect(url_for("login"))
|
|
|
|
error = None
|
|
if request.method == "POST":
|
|
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:
|
|
error = auth.lockout_message(locked_seconds)
|
|
elif not valid:
|
|
just_locked_minutes = auth.record_failed_attempt(user_id)
|
|
error = auth.lockout_message(just_locked_minutes * 60) if just_locked_minutes else "Code invalide."
|
|
else:
|
|
# Connexion réellement aboutie (mot de passe ET code corrects) :
|
|
# seul ce point remet le compteur d'échecs à zéro — pas le
|
|
# succès du mot de passe seul (voir login.py), sinon un
|
|
# attaquant qui connaît déjà le mot de passe aurait un nombre
|
|
# illimité d'essais sur le code 2FA.
|
|
auth.reset_failed_attempts(user_id)
|
|
next_url = session.pop("pending_login_next", "") or ""
|
|
session.pop("pending_login_user_id", None)
|
|
session["user_id"] = user_id
|
|
# "/" seul (chemin relatif interne) accepté, jamais une URL
|
|
# absolue/protocole-relative ("//evil.com") — sans ce garde,
|
|
# "next" (posé par core/auth_guard.py au moment d'une
|
|
# redirection vers /login) serait une ouverture classique de
|
|
# redirection ouverte (open redirect).
|
|
if next_url.startswith("/") and not next_url.startswith("//"):
|
|
return redirect(next_url)
|
|
if user["role"] == "admin":
|
|
return redirect(url_for("index"))
|
|
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
|
|
return render_template("auth/login_2fa.html", error=error)
|