Ajoute l'authentification : inscription, mot de passe fort, 2FA obligatoire, isolation par utilisateur
Première des deux grandes fonctionnalités demandées (authentification d'abord, export HTML/CSS/JS autonome ensuite) : - Inscription (nom, prénom, email UNIQUE, mot de passe) avec schéma visuel du mot de passe (jauge + liste de critères qui passent au vert en direct — auth/password_strength.py, mêmes règles vérifiées côté serveur qu'affichées côté client). - Double authentification (TOTP, compatible Google Authenticator/Authy) OBLIGATOIRE dès l'inscription : QR code (SVG, sans dépendance Pillow) à scanner puis code à confirmer avant que le compte soit utilisable — voir auth/create_user.py (totp_confirmed) et routes/auth/register_2fa.py. - Connexion en 2 temps (mot de passe puis code TOTP), déconnexion. - Isolation par utilisateur : un compte "user" est limité à un SEUL projet, dont le dossier est nommé d'après son adresse email (slugifiée) et créé automatiquement dès la 2FA confirmée — aucune page de gestion multi-jeux pour lui (redirigé directement vers son propre tableau de bord). Le rôle "admin" reste illimité, comme le moteur l'a toujours été (le TOUT PREMIER compte jamais créé sur une base de comptes vide devient automatiquement admin — voir auth/is_first_user.py — pas de mot de passe par défaut à faire circuler : s'inscrire en premier suffit). Un compte "user" ne peut pas non plus supprimer son unique projet (aucune façon d'en recréer un ensuite). - Garde d'accès globale (core/auth_guard.py, un seul before_request) : toute page exige une connexion, sans avoir touché individuellement aux ~80 routes déjà existantes du moteur. tests/conftest.py isole complètement les tests de la vraie base de comptes (FORGE_USERS_DB_PATH/FORGE_SECRET_KEY_PATH vers un dossier temporaire propre à la session de tests) et authentifie automatiquement la fixture `client` partagée en tant que compte admin de test — les 155 tests déjà existants continuent de passer SANS AUCUNE modification de leur côté, exactement comme avant l'authentification. 10 nouveaux tests dédiés (tests/test_auth.py) : inscription/mots de passe/2FA/connexion/ isolation par projet/blocage de suppression, vérifiés en conditions réelles (vrai client de test Flask, vraie base SQLite, vrais codes TOTP calculés avec pyotp). 165 tests au total, tous au vert. Nouvelles dépendances : pyotp, qrcode (requirements.txt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ed4dd78dad
commit
d90abc827b
+1
-1
@@ -1 +1 @@
|
||||
from . import games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars
|
||||
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import register, register_2fa, login, login_2fa, logout
|
||||
@@ -0,0 +1,32 @@
|
||||
from flask import redirect, render_template, request, session, url_for
|
||||
|
||||
import auth
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if session.get("user_id"):
|
||||
return redirect(url_for("index"))
|
||||
error = None
|
||||
email = ""
|
||||
next_url = request.values.get("next") or ""
|
||||
if not (next_url.startswith("/") and not next_url.startswith("//")):
|
||||
next_url = ""
|
||||
if request.method == "POST":
|
||||
email = request.form.get("email", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
next_url = request.form.get("next") or next_url
|
||||
user = auth.get_user_by_email(email)
|
||||
if not user or not auth.verify_password(user, password):
|
||||
error = "Adresse email ou mot de passe incorrect."
|
||||
else:
|
||||
# Mot de passe correct, mais pas encore connecté : le second
|
||||
# facteur (TOTP) reste à vérifier — voir login_2fa.py. Session
|
||||
# "en attente" distincte de "user_id" (la vraie connexion),
|
||||
# pour qu'un mot de passe seul ne suffise jamais à se connecter.
|
||||
session["pending_login_user_id"] = user["id"]
|
||||
session["pending_login_next"] = next_url
|
||||
return redirect(url_for("login_2fa"))
|
||||
return render_template("auth/login.html", error=error, email=email, next_url=next_url)
|
||||
@@ -0,0 +1,37 @@
|
||||
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":
|
||||
code = request.form.get("code", "")
|
||||
if not auth.verify_totp(user["totp_secret"], code):
|
||||
error = "Code invalide."
|
||||
else:
|
||||
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)
|
||||
@@ -0,0 +1,9 @@
|
||||
from flask import redirect, session, url_for
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/logout", methods=["POST"])
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("login"))
|
||||
@@ -0,0 +1,36 @@
|
||||
from flask import redirect, render_template, request, session, url_for
|
||||
|
||||
import auth
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
error = None
|
||||
form = {"email": "", "nom": "", "prenom": ""}
|
||||
if request.method == "POST":
|
||||
form["email"] = request.form.get("email", "").strip()
|
||||
form["nom"] = request.form.get("nom", "").strip()
|
||||
form["prenom"] = request.form.get("prenom", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
password_confirm = request.form.get("password_confirm", "")
|
||||
if password != password_confirm:
|
||||
error = "Les deux mots de passe ne correspondent pas."
|
||||
else:
|
||||
try:
|
||||
user_id = auth.create_user(form["email"], password, form["nom"], form["prenom"])
|
||||
except auth.UserCreationError as exc:
|
||||
error = str(exc)
|
||||
else:
|
||||
# Le compte existe mais n'est pas encore utilisable (2FA
|
||||
# pas encore confirmée, voir create_user.py) — une session
|
||||
# "en attente" DISTINCTE de la vraie connexion (session
|
||||
# "user_id"), pour qu'un compte jamais confirmé ne puisse
|
||||
# jamais se retrouver connecté malgré lui.
|
||||
session["pending_register_user_id"] = user_id
|
||||
return redirect(url_for("register_2fa"))
|
||||
return render_template(
|
||||
"auth/register.html", error=error, form=form,
|
||||
min_score_required=auth.MIN_SCORE_REQUIRED,
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
from flask import redirect, render_template, request, session, url_for
|
||||
|
||||
import auth
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/register/2fa", methods=["GET", "POST"])
|
||||
def register_2fa():
|
||||
user_id = session.get("pending_register_user_id")
|
||||
if not user_id:
|
||||
return redirect(url_for("register"))
|
||||
user = auth.get_user_by_id(user_id)
|
||||
if not user:
|
||||
session.pop("pending_register_user_id", None)
|
||||
return redirect(url_for("register"))
|
||||
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
code = request.form.get("code", "")
|
||||
if not auth.verify_totp(user["totp_secret"], code):
|
||||
error = "Code invalide — vérifie l'heure de ton téléphone et réessaie."
|
||||
else:
|
||||
auth.confirm_totp(user_id)
|
||||
# Un compte "user" (pas "admin") reçoit ICI son unique projet,
|
||||
# nommé d'après son adresse email (voir create_user.py — le
|
||||
# rôle est déjà tranché à la création : premier compte jamais
|
||||
# créé = admin, tous les suivants = user) — un admin, lui,
|
||||
# reste libre de créer ses jeux comme avant ("+ Nouveau jeu"),
|
||||
# aucun projet ne lui est imposé d'office.
|
||||
if user["role"] != "admin":
|
||||
slug = db.create_game(f"Jeu de {user['prenom']}", slug_override=user["email"])
|
||||
auth.set_project_slug(user_id, slug)
|
||||
user = auth.get_user_by_id(user_id)
|
||||
session.pop("pending_register_user_id", None)
|
||||
session["user_id"] = user_id
|
||||
if user["role"] == "admin":
|
||||
return redirect(url_for("index"))
|
||||
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
|
||||
|
||||
qr_svg = auth.totp_qrcode_svg(user["totp_secret"], user["email"])
|
||||
return render_template(
|
||||
"auth/register_2fa.html", error=error, qr_svg=qr_svg,
|
||||
secret=user["totp_secret"], email=user["email"],
|
||||
)
|
||||
Reference in New Issue
Block a user