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")) # Onboarding guidé (voir routes/onboarding/onboarding_new.py) : # un compte "user" peut ne pas encore avoir de projet du tout # (inscription interrompue avant l'onboarding). if not user.get("project_slug"): return redirect(url_for("onboarding_new")) return redirect(url_for("game_dashboard", slug=user["project_slug"])) return render_template("auth/login_2fa.html", error=error)