diff --git a/.gitignore b/.gitignore
index 3f4e6ab9..265df520 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,10 @@ Bug/
# Jeux créés par les utilisateurs (données runtime, pas du code)
projects/
+# Comptes utilisateurs (base SQLite + clé de session) — données runtime,
+# jamais du code, et sensibles (mots de passe hachés, secrets 2FA).
+data/
+
# OS / éditeurs
.DS_Store
Thumbs.db
diff --git a/app.py b/app.py
index 668443b8..373d1814 100644
--- a/app.py
+++ b/app.py
@@ -21,6 +21,7 @@ import webbrowser
from core.flask_app import app
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja
import routes # noqa: F401 - enregistre toutes les routes sur `app`
+from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes)
def _open_browser():
diff --git a/auth/__init__.py b/auth/__init__.py
new file mode 100644
index 00000000..94bd33e1
--- /dev/null
+++ b/auth/__init__.py
@@ -0,0 +1,27 @@
+"""auth — comptes utilisateurs, connexion, 2FA (TOTP) et isolation par
+utilisateur d'un SEUL projet (sauf le rôle "admin", illimité) — voir
+create_user.py pour le détail des règles. Base SQLite entièrement séparée
+de db/ (une base par JEU) : ces comptes n'appartiennent à aucun jeu, ils
+en POSSÈDENT un (project_slug)."""
+
+from .connection import users_db_path
+from .ensure_schema import ensure_users_schema
+from .is_first_user import is_first_user
+from .create_user import create_user, UserCreationError
+from .get_user_by_email import get_user_by_email
+from .get_user_by_id import get_user_by_id
+from .verify_password import verify_password
+from .confirm_totp import confirm_totp
+from .verify_totp import verify_totp
+from .set_project_slug import set_project_slug
+from .password_strength import password_strength, MIN_SCORE_REQUIRED
+from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg
+
+__all__ = [
+ "users_db_path", "ensure_users_schema", "is_first_user",
+ "create_user", "UserCreationError",
+ "get_user_by_email", "get_user_by_id",
+ "verify_password", "confirm_totp", "verify_totp", "set_project_slug",
+ "password_strength", "MIN_SCORE_REQUIRED",
+ "totp_provisioning_uri", "totp_qrcode_svg",
+]
diff --git a/auth/confirm_totp.py b/auth/confirm_totp.py
new file mode 100644
index 00000000..7d91b14a
--- /dev/null
+++ b/auth/confirm_totp.py
@@ -0,0 +1,8 @@
+from .connection import connect
+
+
+def confirm_totp(user_id):
+ conn = connect()
+ conn.execute("UPDATE _users SET totp_confirmed = 1 WHERE id = ?", (user_id,))
+ conn.commit()
+ conn.close()
diff --git a/auth/connection.py b/auth/connection.py
new file mode 100644
index 00000000..4b5ee4f0
--- /dev/null
+++ b/auth/connection.py
@@ -0,0 +1,28 @@
+"""Connexion à la base SQLite GLOBALE des comptes utilisateurs — SÉPARÉE
+de db/ (une base par JEU, voir db/connection.py) : les comptes ne
+"appartiennent" à aucun jeu en particulier, ils en POSSÈDENT un (voir
+auth/create_user.py, project_slug).
+
+Le chemin se résout à CHAQUE appel (pas une constante figée à l'import) :
+tests/conftest.py positionne FORGE_USERS_DB_PATH vers un fichier temporaire
+avant de faire tourner la suite, pour ne jamais toucher à la vraie base de
+comptes (data/users.db) ni dépendre d'un état "premier compte = admin" déjà
+consommé par un run précédent."""
+import os
+import sqlite3
+
+_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DEFAULT_USERS_DB_PATH = os.path.join(_BASE_DIR, "data", "users.db")
+
+
+def users_db_path():
+ return os.environ.get("FORGE_USERS_DB_PATH") or DEFAULT_USERS_DB_PATH
+
+
+def connect():
+ path = users_db_path()
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ conn = sqlite3.connect(path, timeout=10)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA busy_timeout = 8000")
+ return conn
diff --git a/auth/create_user.py b/auth/create_user.py
new file mode 100644
index 00000000..ade5d229
--- /dev/null
+++ b/auth/create_user.py
@@ -0,0 +1,53 @@
+import re
+
+import pyotp
+from werkzeug.security import generate_password_hash
+
+from .ensure_schema import ensure_users_schema
+from .connection import connect
+from .get_user_by_email import get_user_by_email
+from .is_first_user import is_first_user
+from .password_strength import password_strength
+
+_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
+
+
+class UserCreationError(Exception):
+ """Erreur de validation lisible par un humain (affichée telle quelle
+ dans le formulaire d'inscription) — jamais un détail SQL/technique."""
+
+
+def create_user(email, password, nom, prenom):
+ """Crée un compte : mot de passe fort (auth/password_strength.py) et
+ 2FA (TOTP) rendus obligatoires — le secret est généré ici mais
+ totp_confirmed reste à 0 tant que confirm_totp() n'a pas vérifié un
+ premier code réellement scanné/tapé (voir routes/auth/register_2fa.py),
+ pour ne jamais activer un compte sur un secret QR jamais réellement
+ configuré. Le TOUT PREMIER compte créé (base vide) devient
+ automatiquement admin (voir is_first_user.py) : pas de mot de passe
+ par défaut à faire circuler, juste "inscris-toi en premier"."""
+ email = (email or "").strip().lower()
+ nom = (nom or "").strip()
+ prenom = (prenom or "").strip()
+ if not _EMAIL_RE.match(email):
+ raise UserCreationError("Adresse email invalide.")
+ if not nom or not prenom:
+ raise UserCreationError("Nom et prénom sont requis.")
+ if get_user_by_email(email):
+ raise UserCreationError("Un compte existe déjà avec cette adresse email.")
+ if not password_strength(password)["valid"]:
+ raise UserCreationError("Mot de passe trop faible — respecte les critères affichés.")
+
+ ensure_users_schema()
+ role = "admin" if is_first_user() else "user"
+ totp_secret = pyotp.random_base32()
+ conn = connect()
+ conn.execute(
+ """INSERT INTO _users (email, password_hash, nom, prenom, role, totp_secret, totp_confirmed)
+ VALUES (?, ?, ?, ?, ?, ?, 0)""",
+ (email, generate_password_hash(password), nom, prenom, role, totp_secret),
+ )
+ user_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
+ conn.commit()
+ conn.close()
+ return user_id
diff --git a/auth/ensure_schema.py b/auth/ensure_schema.py
new file mode 100644
index 00000000..db4fe0b8
--- /dev/null
+++ b/auth/ensure_schema.py
@@ -0,0 +1,23 @@
+from .connection import connect
+
+
+def ensure_users_schema():
+ conn = connect()
+ conn.executescript(
+ """
+ CREATE TABLE IF NOT EXISTS _users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ nom TEXT NOT NULL,
+ prenom TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'user',
+ totp_secret TEXT NOT NULL,
+ totp_confirmed INTEGER NOT NULL DEFAULT 0,
+ project_slug TEXT,
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
+ );
+ """
+ )
+ conn.commit()
+ conn.close()
diff --git a/auth/get_user_by_email.py b/auth/get_user_by_email.py
new file mode 100644
index 00000000..c7519875
--- /dev/null
+++ b/auth/get_user_by_email.py
@@ -0,0 +1,10 @@
+from .ensure_schema import ensure_users_schema
+from .connection import connect
+
+
+def get_user_by_email(email):
+ ensure_users_schema()
+ conn = connect()
+ row = conn.execute("SELECT * FROM _users WHERE email = ?", ((email or "").strip().lower(),)).fetchone()
+ conn.close()
+ return dict(row) if row else None
diff --git a/auth/get_user_by_id.py b/auth/get_user_by_id.py
new file mode 100644
index 00000000..f95014c6
--- /dev/null
+++ b/auth/get_user_by_id.py
@@ -0,0 +1,12 @@
+from .ensure_schema import ensure_users_schema
+from .connection import connect
+
+
+def get_user_by_id(user_id):
+ ensure_users_schema()
+ if not user_id:
+ return None
+ conn = connect()
+ row = conn.execute("SELECT * FROM _users WHERE id = ?", (user_id,)).fetchone()
+ conn.close()
+ return dict(row) if row else None
diff --git a/auth/is_first_user.py b/auth/is_first_user.py
new file mode 100644
index 00000000..cc73ef6d
--- /dev/null
+++ b/auth/is_first_user.py
@@ -0,0 +1,14 @@
+from .ensure_schema import ensure_users_schema
+from .connection import connect
+
+
+def is_first_user():
+ """True s'il n'existe encore AUCUN compte — le tout premier compte créé
+ devient automatiquement admin (voir create_user.py), pour ne jamais
+ avoir besoin d'un mot de passe par défaut ou d'un script de bootstrap
+ manipulant un vrai mot de passe."""
+ ensure_users_schema()
+ conn = connect()
+ count = conn.execute("SELECT COUNT(*) AS c FROM _users").fetchone()["c"]
+ conn.close()
+ return count == 0
diff --git a/auth/password_strength.py b/auth/password_strength.py
new file mode 100644
index 00000000..6ea9c3af
--- /dev/null
+++ b/auth/password_strength.py
@@ -0,0 +1,29 @@
+import re
+
+# Mêmes règles des deux côtés (ici pour le refus serveur, en JS dans
+# templates/auth/register.html pour le schéma visuel qui guide la saisie
+# en temps réel) — un mot de passe REFUSÉ côté serveur doit toujours
+# correspondre à une jauge/coche déjà rouge côté client, jamais une
+# surprise après coup.
+_CHECKS = [
+ ("longueur", "Au moins 8 caractères", lambda p: len(p) >= 8),
+ ("majuscule", "Une majuscule", lambda p: re.search(r"[A-Z]", p) is not None),
+ ("minuscule", "Une minuscule", lambda p: re.search(r"[a-z]", p) is not None),
+ ("chiffre", "Un chiffre", lambda p: re.search(r"\d", p) is not None),
+ ("special", "Un caractère spécial (ex. ! ? # -)", lambda p: re.search(r"[^A-Za-z0-9]", p) is not None),
+]
+
+# Nombre de règles satisfaites en dessous duquel le mot de passe est
+# refusé — un mot de passe "fort" au sens de cette jauge, pas seulement
+# "pas complètement trivial".
+MIN_SCORE_REQUIRED = 4
+
+
+def password_strength(password):
+ """Renvoie {"score": 0-5, "checks": [{"key","label","ok"}, ...],
+ "valid": bool} — jamais None, un mot de passe vide obtient juste un
+ score de 0 (toutes les règles échouent), pas une erreur."""
+ password = password or ""
+ checks = [{"key": key, "label": label, "ok": bool(test(password))} for key, label, test in _CHECKS]
+ score = sum(1 for c in checks if c["ok"])
+ return {"score": score, "checks": checks, "valid": score >= MIN_SCORE_REQUIRED}
diff --git a/auth/set_project_slug.py b/auth/set_project_slug.py
new file mode 100644
index 00000000..dcb84fd6
--- /dev/null
+++ b/auth/set_project_slug.py
@@ -0,0 +1,12 @@
+from .connection import connect
+
+
+def set_project_slug(user_id, slug):
+ """Enregistre le SEUL projet que ce compte possède (voir
+ create_user.py — un compte "user" n'en aura jamais qu'un ; un compte
+ "admin" n'a PAS besoin de cette colonne, il reste libre de créer
+ plusieurs jeux nommés comme avant)."""
+ conn = connect()
+ conn.execute("UPDATE _users SET project_slug = ? WHERE id = ?", (slug, user_id))
+ conn.commit()
+ conn.close()
diff --git a/auth/totp_qrcode_svg.py b/auth/totp_qrcode_svg.py
new file mode 100644
index 00000000..e4f684d9
--- /dev/null
+++ b/auth/totp_qrcode_svg.py
@@ -0,0 +1,21 @@
+import io
+
+import pyotp
+import qrcode
+import qrcode.image.svg
+
+
+def totp_provisioning_uri(secret, email):
+ return pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="Forge Engine")
+
+
+def totp_qrcode_svg(secret, email):
+ """SVG (pas PNG) : la variante "image factory" par défaut de qrcode a
+ besoin de Pillow pour produire un PNG — SvgImage, elle, est du pur
+ Python, sans dépendance supplémentaire à installer juste pour un QR
+ code affiché une seule fois par compte, à l'inscription."""
+ uri = totp_provisioning_uri(secret, email)
+ img = qrcode.make(uri, image_factory=qrcode.image.svg.SvgImage)
+ buf = io.BytesIO()
+ img.save(buf)
+ return buf.getvalue().decode("utf-8")
diff --git a/auth/verify_password.py b/auth/verify_password.py
new file mode 100644
index 00000000..905a31d7
--- /dev/null
+++ b/auth/verify_password.py
@@ -0,0 +1,5 @@
+from werkzeug.security import check_password_hash
+
+
+def verify_password(user, password):
+ return bool(user) and check_password_hash(user["password_hash"], password or "")
diff --git a/auth/verify_totp.py b/auth/verify_totp.py
new file mode 100644
index 00000000..7dfbe530
--- /dev/null
+++ b/auth/verify_totp.py
@@ -0,0 +1,12 @@
+import pyotp
+
+
+def verify_totp(secret, code):
+ """valid_window=1 : tolère un léger décalage d'horloge entre le
+ serveur et le téléphone (accepte aussi le code de la période
+ précédente/suivante, ±30s) — sans quoi une horloge un peu désynchronisée
+ suffirait à rendre la 2FA injouable."""
+ code = (code or "").strip()
+ if not code:
+ return False
+ return pyotp.TOTP(secret).verify(code, valid_window=1)
diff --git a/core/auth_guard.py b/core/auth_guard.py
new file mode 100644
index 00000000..644ce85e
--- /dev/null
+++ b/core/auth_guard.py
@@ -0,0 +1,64 @@
+"""Garde d'accès globale — connexion obligatoire pour tout le moteur, et
+isolation par utilisateur d'un SEUL projet (sauf le rôle "admin",
+illimité comme avant l'authentification). Un seul before_request plutôt
+qu'un décorateur à poser sur chacune des ~80 routes existantes : moins de
+risque d'en oublier une, et aucun fichier de routes existant n'a besoin
+d'être modifié.
+
+Cet import doit avoir lieu APRÈS `import routes` (voir app.py/conftest.py)
+pour que `app.url_map` connaisse déjà toutes les routes au moment où ce
+module tente de résoudre request.endpoint — en pratique sans importance
+ici (la résolution se fait à la requête, pas à l'import), mais gardé pour
+rester cohérent avec l'ordre d'import du reste du moteur."""
+from flask import g, redirect, request, session, url_for, abort
+
+import auth
+
+from .flask_app import app
+
+# Endpoints accessibles SANS être connecté — tout le reste exige une
+# session valide. "static" (CSS/JS/images) doit rester public : la page
+# de connexion elle-même en a besoin pour s'afficher.
+_PUBLIC_ENDPOINTS = {
+ "static", "login", "login_2fa", "register", "register_2fa", "logout",
+}
+
+
+@app.before_request
+def _require_login_and_enforce_project_isolation():
+ endpoint = request.endpoint
+ if endpoint is None or endpoint in _PUBLIC_ENDPOINTS:
+ return None
+
+ user_id = session.get("user_id")
+ if not user_id:
+ return redirect(url_for("login", next=request.path))
+
+ user = auth.get_user_by_id(user_id)
+ if not user:
+ # Compte supprimé/introuvable depuis la dernière requête : la
+ # session ne doit jamais rester "connectée" dans le vide.
+ session.clear()
+ return redirect(url_for("login"))
+ g.current_user = user
+
+ # Un compte "user" n'a accès qu'à SON SEUL projet (project_slug) — un
+ # "admin" reste illimité, exactement comme avant l'authentification.
+ # Les routes de gestion multi-jeux (page d'accueil, "+ Nouveau jeu")
+ # n'ont pas leur place pour un compte à projet unique : redirigées
+ # directement vers son propre tableau de bord plutôt qu'un 403 sec.
+ if user["role"] != "admin":
+ if endpoint in ("index", "games_new") and user.get("project_slug"):
+ return redirect(url_for("game_dashboard", slug=user["project_slug"]))
+ slug = request.view_args.get("slug") if request.view_args else None
+ if slug is not None and slug != user.get("project_slug"):
+ abort(403)
+ if endpoint == "game_delete":
+ # Un compte "user" n'a qu'UN SEUL projet, et aucun moyen d'en
+ # recréer un une fois supprimé (games_new le renvoie toujours
+ # vers project_slug, même si le dossier n'existe plus) —
+ # bloqué plutôt que de risquer de le laisser sans projet du
+ # tout. Un admin, lui, peut toujours supprimer ses jeux comme
+ # avant (aucune restriction ci-dessus pour ce rôle).
+ abort(403)
+ return None
diff --git a/core/flask_app.py b/core/flask_app.py
index 58cbccf6..effee6e5 100644
--- a/core/flask_app.py
+++ b/core/flask_app.py
@@ -1,4 +1,5 @@
import os
+import secrets
from flask import Flask
@@ -7,3 +8,27 @@ _TEMPLATE_FOLDER = os.path.join(_BASE_DIR, "templates")
_STATIC_FOLDER = os.path.join(_BASE_DIR, "static")
app = Flask(__name__, template_folder=_TEMPLATE_FOLDER, static_folder=_STATIC_FOLDER)
+
+
+def _load_or_create_secret_key():
+ """Nécessaire dès qu'une session Flask (flask.session) est utilisée —
+ ici pour se souvenir de qui est connecté (auth/). Généré une seule
+ fois et PERSISTÉ dans un fichier (jamais en dur dans le code, jamais
+ commité — voir .gitignore, data/) : sans ça, chaque redémarrage du
+ serveur changerait la clé et déconnecterait tout le monde. Le chemin
+ est overridable (FORGE_SECRET_KEY_PATH) pour que les tests utilisent
+ un fichier temporaire, jamais celui du poste réel."""
+ path = os.environ.get("FORGE_SECRET_KEY_PATH") or os.path.join(_BASE_DIR, "data", "secret_key")
+ if os.path.isfile(path):
+ with open(path, "r", encoding="utf-8") as f:
+ key = f.read().strip()
+ if key:
+ return key
+ key = secrets.token_hex(32)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(key)
+ return key
+
+
+app.secret_key = _load_or_create_secret_key()
diff --git a/db/games/create_game.py b/db/games/create_game.py
index 570bb032..055787c7 100644
--- a/db/games/create_game.py
+++ b/db/games/create_game.py
@@ -5,10 +5,16 @@ from ..game_dir import game_dir
from ..slugify import slugify
-def create_game(name):
+def create_game(name, slug_override=None):
"""Feature 1 : crée le dossier du jeu, ses fichiers index.html/css/js
- reliés entre eux, et sa base de données dédiée (nom du jeu en méta)."""
- slug = slugify(name)
+ reliés entre eux, et sa base de données dédiée (nom du jeu en méta).
+
+ slug_override : impose le nom du dossier au lieu de le dériver de
+ `name` — utilisé par l'inscription (auth/) pour que le dossier d'un
+ compte porte son adresse email (slugifiée), indépendamment du nom
+ donné à son jeu. Toujours rendu unique de la même façon (suffixe
+ -2/-3...) si, par un hasard extrême, le dossier existe déjà."""
+ slug = slugify(slug_override) if slug_override else slugify(name)
base = slug
n = 2
while os.path.isdir(game_dir(base)):
diff --git a/requirements.txt b/requirements.txt
index 95fef4eb..66d01c43 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1 +1,3 @@
Flask==3.0.3
+pyotp==2.10.0
+qrcode==8.2
diff --git a/routes/__init__.py b/routes/__init__.py
index a4b6f4b4..995d16be 100644
--- a/routes/__init__.py
+++ b/routes/__init__.py
@@ -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
diff --git a/routes/auth/__init__.py b/routes/auth/__init__.py
new file mode 100644
index 00000000..8409d75e
--- /dev/null
+++ b/routes/auth/__init__.py
@@ -0,0 +1 @@
+from . import register, register_2fa, login, login_2fa, logout
diff --git a/routes/auth/login.py b/routes/auth/login.py
new file mode 100644
index 00000000..f172a572
--- /dev/null
+++ b/routes/auth/login.py
@@ -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)
diff --git a/routes/auth/login_2fa.py b/routes/auth/login_2fa.py
new file mode 100644
index 00000000..dc9f6bd3
--- /dev/null
+++ b/routes/auth/login_2fa.py
@@ -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)
diff --git a/routes/auth/logout.py b/routes/auth/logout.py
new file mode 100644
index 00000000..4b7ccff9
--- /dev/null
+++ b/routes/auth/logout.py
@@ -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"))
diff --git a/routes/auth/register.py b/routes/auth/register.py
new file mode 100644
index 00000000..e1504bff
--- /dev/null
+++ b/routes/auth/register.py
@@ -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,
+ )
diff --git a/routes/auth/register_2fa.py b/routes/auth/register_2fa.py
new file mode 100644
index 00000000..b4f34fb0
--- /dev/null
+++ b/routes/auth/register_2fa.py
@@ -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"],
+ )
diff --git a/static/style.css b/static/style.css
index ed888f3d..b64099d4 100644
--- a/static/style.css
+++ b/static/style.css
@@ -569,3 +569,22 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
Tableau n'est qu'une liste de valeurs, voir openVarValuePanel() dans
game_dashboard.html. */
#varValuePanel[data-mode="tableau"] .varValueKeyField{ display:none; }
+
+/* ---------- Inscription : schéma visuel du mot de passe ---------- */
+/* Jauge + liste de critères mise à jour en direct (voir register.html) —
+ les mêmes 5 critères que auth/password_strength.py côté serveur. */
+.passwordStrengthBar{ height:6px; border-radius:3px; background:var(--panel2); overflow:hidden; margin-top:8px; }
+.passwordStrengthFill{ height:100%; width:0; border-radius:3px; background:var(--danger, #e05252); transition:width .15s ease, background-color .15s ease; }
+.passwordStrengthFill.weak{ background:var(--danger, #e05252); }
+.passwordStrengthFill.medium{ background:#e0a952; }
+.passwordStrengthFill.good{ background:#d7d652; }
+.passwordStrengthFill.strong{ background:#4caf50; }
+.passwordChecklist{ list-style:none; margin:8px 0 0; padding:0; display:flex; flex-direction:column; gap:2px; }
+.passwordChecklist li{ font-size:12.5px; color:var(--text-dim); padding-left:18px; position:relative; }
+.passwordChecklist li::before{ content:"○"; position:absolute; left:0; }
+.passwordChecklist li.ok{ color:#4caf50; }
+.passwordChecklist li.ok::before{ content:"✓"; }
+
+/* ---------- Inscription : QR code de la double authentification ---------- */
+.totpQrWrap{ background:#fff; padding:16px; border-radius:8px; display:inline-block; max-width:220px; }
+.totpQrWrap svg{ display:block; width:100%; height:auto; }
diff --git a/templates/auth/login.html b/templates/auth/login.html
new file mode 100644
index 00000000..270a97e1
--- /dev/null
+++ b/templates/auth/login.html
@@ -0,0 +1,29 @@
+{% extends "base.html" %}
+{% block title %}Connexion — Forge Engine{% endblock %}
+{% block content %}
+ Pas encore de compte ? S'inscrire Ouvre ton application d'authentification et saisis le code à 6 chiffres affiché. Un compte = un seul projet de jeu (illimité pour un compte administrateur). La double authentification (2FA) est obligatoire — l'étape suivante te fera scanner un QR code avec une application comme Google Authenticator. Déjà un compte ? Se connecter Scanne ce QR code avec une application d'authentification (Google Authenticator, Authy...), puis tape le code à 6 chiffres qu'elle affiche pour confirmer. Impossible de scanner ? Saisis cette clé manuellement dans l'application : Connexion
+
+ {% if error %}
+ Code de vérification
+ Créer un compte
+ Active la double authentification
+ {{ secret }}