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 %} +
+
+

Connexion

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+ +
+ +
+
+
+ +
+
+
+ +
+
+

Pas encore de compte ? S'inscrire

+
+
+{% endblock %} diff --git a/templates/auth/login_2fa.html b/templates/auth/login_2fa.html new file mode 100644 index 00000000..a5b37cb1 --- /dev/null +++ b/templates/auth/login_2fa.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Code de vérification — Forge Engine{% endblock %} +{% block content %} +
+
+

Code de vérification

+

Ouvre ton application d'authentification et saisis le code à 6 chiffres affiché.

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ +
+
+
+ +
+
+
+
+{% endblock %} diff --git a/templates/auth/register.html b/templates/auth/register.html new file mode 100644 index 00000000..799592ad --- /dev/null +++ b/templates/auth/register.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} +{% block title %}Inscription — Forge Engine{% endblock %} +{% block content %} +
+
+

Créer un compte

+

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.

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+ + +
+
+
    +
  • Au moins 8 caractères
  • +
  • Une majuscule
  • +
  • Une minuscule
  • +
  • Un chiffre
  • +
  • Un caractère spécial (ex. ! ? # -)
  • +
+
+ +
+ +
+
+ +
+ +
+
+

Déjà un compte ? Se connecter

+
+
+ + +{% endblock %} diff --git a/templates/auth/register_2fa.html b/templates/auth/register_2fa.html new file mode 100644 index 00000000..d502d4d8 --- /dev/null +++ b/templates/auth/register_2fa.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Activer la double authentification — Forge Engine{% endblock %} +{% block content %} +
+
+

Active la double authentification

+

Scanne ce QR code avec une application d'authentification (Google Authenticator, Authy...), puis tape le code à 6 chiffres qu'elle affiche pour confirmer.

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
{{ qr_svg|safe }}
+

Impossible de scanner ? Saisis cette clé manuellement dans l'application : {{ secret }}

+ +
+
+ +
+
+
+ +
+
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html index 63ac44dc..4cc63ab6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -25,6 +25,12 @@ + {% if g.get('current_user') %} + + {% endif %} {% if game is defined and game %}