diff --git a/.env.example b/.env.example index d2e76925..398b04c1 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,14 @@ IMAGE_TAG=latest # Port exposé sur l'hôte de production PORT=5050 + +# Réinitialisation de mot de passe par email (auth/send_email.py) — un +# serveur SMTP existant (ex. Mailcow) suffit, aucun service tiers requis. +# Laisser vide désactive l'envoi (le lien de réinitialisation n'est alors +# jamais transmis à l'utilisateur, seule une erreur est journalisée côté +# serveur — jamais montrée telle quelle à l'utilisateur). +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= diff --git a/.gitignore b/.gitignore index 78597e9c..265df520 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ .venv/ venv/ env/ +Bug/ # Variables d'environnement / secrets locaux .env @@ -16,6 +17,10 @@ env/ # 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..d827796d 100644 --- a/app.py +++ b/app.py @@ -21,6 +21,10 @@ 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) +from core import csrf # noqa: F401 - enregistre csrf_token() comme variable globale Jinja +from core import csrf_guard # noqa: F401 - enregistre la vérification du jeton CSRF +from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja def _open_browser(): diff --git a/auth/__init__.py b/auth/__init__.py new file mode 100644 index 00000000..abd5589b --- /dev/null +++ b/auth/__init__.py @@ -0,0 +1,47 @@ +"""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 +from .rate_limit import lockout_minutes_for, seconds_locked_remaining, lockout_message +from .record_failed_attempt import record_failed_attempt +from .reset_failed_attempts import reset_failed_attempts +from .recovery_codes import generate_recovery_codes, verify_recovery_code +from .set_password import set_password +from .password_reset import ( + create_password_reset_token, get_user_id_for_valid_token, consume_password_reset_token, + TOKEN_TTL_MINUTES, +) +from .send_email import send_password_reset_email, EmailNotConfiguredError +from .update_profile import update_profile +from .count_admins import count_admins +from .delete_user import delete_user + +__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", + "lockout_minutes_for", "seconds_locked_remaining", "lockout_message", + "record_failed_attempt", "reset_failed_attempts", + "generate_recovery_codes", "verify_recovery_code", + "set_password", "create_password_reset_token", "get_user_id_for_valid_token", + "consume_password_reset_token", "TOKEN_TTL_MINUTES", + "send_password_reset_email", "EmailNotConfiguredError", + "update_profile", "count_admins", "delete_user", +] 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/count_admins.py b/auth/count_admins.py new file mode 100644 index 00000000..43647438 --- /dev/null +++ b/auth/count_admins.py @@ -0,0 +1,8 @@ +from .connection import connect + + +def count_admins(): + conn = connect() + n = conn.execute("SELECT COUNT(*) AS n FROM _users WHERE role = 'admin'").fetchone()["n"] + conn.close() + return n 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/delete_user.py b/auth/delete_user.py new file mode 100644 index 00000000..85801ea4 --- /dev/null +++ b/auth/delete_user.py @@ -0,0 +1,16 @@ +from .connection import connect + + +def delete_user(user_id): + """Supprime le compte et tout ce qui lui est directement rattaché + (codes de récupération, jetons de réinitialisation encore en cours) — + le dossier de projet, lui, est géré par l'appelant (voir + routes/auth/profile.py) : un compte "admin" peut posséder plusieurs + jeux qu'il n'a pas forcément envie de perdre en même temps que son + compte.""" + conn = connect() + conn.execute("DELETE FROM _recovery_codes WHERE user_id = ?", (user_id,)) + conn.execute("DELETE FROM _password_reset_tokens WHERE user_id = ?", (user_id,)) + conn.execute("DELETE FROM _users WHERE id = ?", (user_id,)) + conn.commit() + conn.close() diff --git a/auth/ensure_schema.py b/auth/ensure_schema.py new file mode 100644 index 00000000..9d631891 --- /dev/null +++ b/auth/ensure_schema.py @@ -0,0 +1,65 @@ +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 + ); + """ + ) + # Anti-bruteforce (voir rate_limit.py) : ajoutées en ALTER TABLE pour + # ne rien casser sur une base de comptes déjà créée avant cette + # fonctionnalité (le compte admin déjà inscrit sur ce poste, par + # exemple) — CREATE TABLE IF NOT EXISTS ci-dessus n'aurait sinon + # aucun effet sur une table qui existe déjà sans ces colonnes. + cols = {r["name"] for r in conn.execute("PRAGMA table_info(_users)").fetchall()} + if "failed_attempts" not in cols: + conn.execute("ALTER TABLE _users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0") + if "locked_until" not in cols: + conn.execute("ALTER TABLE _users ADD COLUMN locked_until TEXT") + # Codes de récupération 2FA (voir recovery_codes.py) : table séparée + # plutôt qu'une colonne JSON sur _users, pour pouvoir marquer/consommer + # chaque code UN PAR UN (used_at) sans avoir à relire/réécrire toute + # la liste à chaque connexion. + conn.execute( + """ + CREATE TABLE IF NOT EXISTS _recovery_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + code_hash TEXT NOT NULL, + used_at TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + # Jetons de réinitialisation de mot de passe (voir password_reset.py) : + # seul leur hash (SHA-256, un jeton aléatoire à haute entropie n'a pas + # besoin d'un hash de mot de passe volontairement lent) est stocké, un + # jeton envoyé par email reste donc inutilisable même en cas de fuite + # de cette base. + conn.execute( + """ + CREATE TABLE IF NOT EXISTS _password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + used_at 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_reset.py b/auth/password_reset.py new file mode 100644 index 00000000..7b7c7661 --- /dev/null +++ b/auth/password_reset.py @@ -0,0 +1,55 @@ +import hashlib +import secrets +from datetime import datetime, timedelta, timezone + +from .connection import connect + +TOKEN_TTL_MINUTES = 60 + + +def _hash_token(token): + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def create_password_reset_token(user_id): + """Un seul jeton valide à la fois par utilisateur : en créer un + nouveau invalide silencieusement tout jeu envoyé plus tôt (par + exemple si l'utilisateur redemande un email parce que le premier + s'est perdu), pour ne jamais avoir deux liens valides en même temps.""" + token = secrets.token_urlsafe(32) + expires_at = (datetime.now(timezone.utc) + timedelta(minutes=TOKEN_TTL_MINUTES)).isoformat() + conn = connect() + conn.execute("DELETE FROM _password_reset_tokens WHERE user_id = ?", (user_id,)) + conn.execute( + "INSERT INTO _password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)", + (user_id, _hash_token(token), expires_at), + ) + conn.commit() + conn.close() + return token + + +def get_user_id_for_valid_token(token): + if not token: + return None + conn = connect() + row = conn.execute( + "SELECT user_id, expires_at, used_at FROM _password_reset_tokens WHERE token_hash = ?", + (_hash_token(token),), + ).fetchone() + conn.close() + if not row or row["used_at"]: + return None + if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc): + return None + return row["user_id"] + + +def consume_password_reset_token(token): + conn = connect() + conn.execute( + "UPDATE _password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_hash = ?", + (_hash_token(token),), + ) + conn.commit() + conn.close() 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/rate_limit.py b/auth/rate_limit.py new file mode 100644 index 00000000..2467ef86 --- /dev/null +++ b/auth/rate_limit.py @@ -0,0 +1,50 @@ +"""Anti-bruteforce sur les tentatives de connexion (mot de passe ET code +2FA — même compteur pour les deux, un compte ciblé reste protégé quelle +que soit l'étape attaquée) : 3 essais libres, puis un temps d'attente qui +double à chaque échec supplémentaire (5 min, 10, 20, 40...), plafonné à +1h. Remis à zéro dès une connexion réussie (voir reset_failed_attempts).""" +import math +from datetime import datetime, timedelta, timezone + +_FREE_ATTEMPTS = 3 +_FIRST_LOCKOUT_MINUTES = 5 +_MAX_LOCKOUT_MINUTES = 60 + + +def lockout_minutes_for(failed_attempts): + """0 tant qu'on est dans les 3 essais libres ; sinon 5 * 2^n, plafonné + à 60 — jamais négatif, jamais None.""" + if failed_attempts <= _FREE_ATTEMPTS: + return 0 + exponent = failed_attempts - _FREE_ATTEMPTS - 1 + return min(_FIRST_LOCKOUT_MINUTES * (2 ** exponent), _MAX_LOCKOUT_MINUTES) + + +def _parse(dt_str): + if not dt_str: + return None + try: + return datetime.fromisoformat(dt_str) + except ValueError: + return None + + +def lockout_message(seconds_remaining): + """Message affiché tel quel dans le formulaire — arrondi à la minute + supérieure (jamais "0 minute" alors qu'il en reste un peu, jamais un + compte de secondes qui oblige à recharger sans arrêt pour voir si + c'est fini).""" + minutes = max(1, math.ceil(seconds_remaining / 60)) + unit = "minute" if minutes == 1 else "minutes" + return f"Trop de tentatives. Réessaie dans {minutes} {unit}." + + +def seconds_locked_remaining(user): + """> 0 si le compte est actuellement verrouillé (temps restant, en + secondes, arrondi au-dessus pour ne jamais afficher "0 minute" alors + qu'il en reste réellement un peu) ; 0 sinon.""" + locked_until = _parse((user or {}).get("locked_until")) + if not locked_until: + return 0 + remaining = (locked_until - datetime.now(timezone.utc)).total_seconds() + return max(0, math.ceil(remaining)) diff --git a/auth/record_failed_attempt.py b/auth/record_failed_attempt.py new file mode 100644 index 00000000..46b79ef5 --- /dev/null +++ b/auth/record_failed_attempt.py @@ -0,0 +1,30 @@ +from datetime import datetime, timedelta, timezone + +from .connection import connect +from .rate_limit import lockout_minutes_for + + +def record_failed_attempt(user_id): + """Incrémente le compteur d'échecs de CE compte et, une fois passé les + 3 essais libres, pose/allonge son verrouillage (voir rate_limit.py) — + appelé sur un mot de passe OU un code 2FA incorrect, jamais sur un + compte introuvable (rien à incrémenter dans ce cas). Renvoie le + nombre de minutes du verrouillage tout juste posé (0 si encore dans + les essais libres) : l'appelant peut ainsi annoncer IMMÉDIATEMENT + "trop de tentatives" sur CETTE réponse plutôt que de laisser + l'utilisateur découvrir le verrouillage seulement au prochain essai.""" + conn = connect() + row = conn.execute("SELECT failed_attempts FROM _users WHERE id = ?", (user_id,)).fetchone() + if not row: + conn.close() + return 0 + failed_attempts = row["failed_attempts"] + 1 + minutes = lockout_minutes_for(failed_attempts) + locked_until = (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat() if minutes else None + conn.execute( + "UPDATE _users SET failed_attempts = ?, locked_until = ? WHERE id = ?", + (failed_attempts, locked_until, user_id), + ) + conn.commit() + conn.close() + return minutes diff --git a/auth/recovery_codes.py b/auth/recovery_codes.py new file mode 100644 index 00000000..7c6b8799 --- /dev/null +++ b/auth/recovery_codes.py @@ -0,0 +1,60 @@ +import secrets + +from werkzeug.security import check_password_hash, generate_password_hash + +from .connection import connect + +_CODE_COUNT = 10 + + +def _format_code(raw): + return "-".join(raw[i:i + 4] for i in range(0, len(raw), 4)) + + +def generate_recovery_codes(user_id): + """(Re)génère les codes de récupération 2FA d'un utilisateur : un + nouvel appel invalide tout jeu de codes précédent (un seul jeu valide + à la fois, pour ne jamais avoir à deviner lesquels tiennent encore). + Les codes ne sont renvoyés EN CLAIR qu'ICI, à l'instant de leur + création — seul leur hash (werkzeug, comme les mots de passe) est + conservé en base, pour qu'une fuite de la base ne les rende pas + directement réutilisables.""" + conn = connect() + conn.execute("DELETE FROM _recovery_codes WHERE user_id = ?", (user_id,)) + codes = [] + for _ in range(_CODE_COUNT): + code = _format_code(secrets.token_hex(6)) + codes.append(code) + conn.execute( + "INSERT INTO _recovery_codes (user_id, code_hash) VALUES (?, ?)", + (user_id, generate_password_hash(code)), + ) + conn.commit() + conn.close() + return codes + + +def verify_recovery_code(user_id, code): + """Un code n'est utilisable qu'UNE SEULE FOIS (used_at) : consommé dès + qu'il sert à une connexion réussie, pour qu'un code intercepté une + fois (capture d'écran, historique du navigateur...) ne redonne pas un + accès permanent.""" + code = (code or "").strip().lower() + if not code: + return False + conn = connect() + rows = conn.execute( + "SELECT id, code_hash FROM _recovery_codes WHERE user_id = ? AND used_at IS NULL", + (user_id,), + ).fetchall() + for row in rows: + if check_password_hash(row["code_hash"], code): + conn.execute( + "UPDATE _recovery_codes SET used_at = CURRENT_TIMESTAMP WHERE id = ?", + (row["id"],), + ) + conn.commit() + conn.close() + return True + conn.close() + return False diff --git a/auth/reset_failed_attempts.py b/auth/reset_failed_attempts.py new file mode 100644 index 00000000..057ff22e --- /dev/null +++ b/auth/reset_failed_attempts.py @@ -0,0 +1,12 @@ +from .connection import connect + + +def reset_failed_attempts(user_id): + """Remet le compteur à zéro et lève tout verrouillage — appelé dès + qu'une étape de connexion réussit (mot de passe validé ou code 2FA + validé), pour ne jamais punir un utilisateur légitime qui s'est juste + trompé une fois avant de retrouver le bon mot de passe/code.""" + conn = connect() + conn.execute("UPDATE _users SET failed_attempts = 0, locked_until = NULL WHERE id = ?", (user_id,)) + conn.commit() + conn.close() diff --git a/auth/send_email.py b/auth/send_email.py new file mode 100644 index 00000000..3c9c97ae --- /dev/null +++ b/auth/send_email.py @@ -0,0 +1,52 @@ +"""Envoi d'email via LE SERVEUR SMTP DE L'UTILISATEUR (Mailcow ou autre) — +aucune dépendance supplémentaire, smtplib est dans la bibliothèque standard +Python. Configuré uniquement par variables d'environnement (SMTP_HOST, +SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM) : ce module ne connaît et +ne stocke jamais le mot de passe SMTP en dur, à définir sur le poste/ +serveur qui fait tourner l'appli.""" +import os +import smtplib +from email.mime.text import MIMEText + +from .password_reset import TOKEN_TTL_MINUTES + + +class EmailNotConfiguredError(Exception): + """Levée si les variables d'environnement SMTP_* ne sont pas toutes + définies — jamais renvoyée telle quelle à l'utilisateur (voir + routes/auth/forgot_password.py, qui affiche toujours le même message + générique), seulement journalisée côté serveur pour que l'admin sache + qu'il doit finir de configurer son SMTP.""" + + +def send_email(to_email, subject, body): + host = os.environ.get("SMTP_HOST") + port = int(os.environ.get("SMTP_PORT", "587")) + user = os.environ.get("SMTP_USER") + password = os.environ.get("SMTP_PASSWORD") + sender = os.environ.get("SMTP_FROM") or user + if not host or not user or not password or not sender: + raise EmailNotConfiguredError( + "SMTP_HOST / SMTP_USER / SMTP_PASSWORD / SMTP_FROM doivent être " + "définis en variables d'environnement pour envoyer des emails." + ) + msg = MIMEText(body) + msg["Subject"] = subject + msg["From"] = sender + msg["To"] = to_email + with smtplib.SMTP(host, port, timeout=10) as server: + server.starttls() + server.login(user, password) + server.sendmail(sender, [to_email], msg.as_string()) + + +def send_password_reset_email(to_email, reset_url): + body = ( + "Une réinitialisation de mot de passe a été demandée pour ce compte " + "Forge Engine.\n\n" + f"Clique sur ce lien pour choisir un nouveau mot de passe " + f"(valable {TOKEN_TTL_MINUTES} minutes) :\n{reset_url}\n\n" + "Si tu n'es pas à l'origine de cette demande, ignore cet email : " + "ton mot de passe actuel reste inchangé." + ) + send_email(to_email, "Réinitialisation de ton mot de passe — Forge Engine", body) diff --git a/auth/set_password.py b/auth/set_password.py new file mode 100644 index 00000000..88b1cc72 --- /dev/null +++ b/auth/set_password.py @@ -0,0 +1,17 @@ +from werkzeug.security import generate_password_hash + +from .connection import connect + + +def set_password(user_id, new_password): + """Remet aussi le compteur anti-bruteforce à zéro (auth/rate_limit.py) : + prouver son identité par email est une voie de récupération légitime, + un compte verrouillé après trop d'échecs ne doit pas rester bloqué une + fois le mot de passe changé pour de vrai.""" + conn = connect() + conn.execute( + "UPDATE _users SET password_hash = ?, failed_attempts = 0, locked_until = NULL WHERE id = ?", + (generate_password_hash(new_password), user_id), + ) + conn.commit() + conn.close() 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..337ab2a0 --- /dev/null +++ b/auth/totp_qrcode_svg.py @@ -0,0 +1,33 @@ +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 — SvgPathImage, elle, est du pur + Python, sans dépendance supplémentaire à installer juste pour un QR + code affiché une seule fois par compte, à l'inscription. SvgPathImage + précisément (pas le simple SvgImage) : c'est la seule des deux variantes + dont le racine porte un attribut viewBox — sans lui, la règle CSS + qui fait tenir le QR code dans son cadre (.totpQrWrap svg { width:100% }) + n'a rien à quoi se raccorder pour mettre à l'échelle le dessin interne + (en mm) : le QR code restait invisible/coupé au lieu de s'afficher. + + qrcode.make() préfixe toujours sa sortie d'une déclaration XML + ("") — valide pour un fichier .svg autonome, mais + invalide au milieu d'un document HTML : certains navigateurs + n'affichent alors rien du tout à partir de ce point. On ne garde donc + que ce qui commence à " classique (intercepté par pjax.js, voir static/pjax.js) ou +un fetch() construit à la main (screen_edit.html, game_dashboard.html, +play.html...) — voir static/csrf_fetch.js, qui l'ajoute automatiquement en +en-tête à CHAQUE fetch() non-GET de l'appli plutôt que de devoir modifier +individuellement chacun des nombreux appels existants.""" +import secrets + +from flask import session + +from .flask_app import app + + +def get_csrf_token(): + token = session.get("csrf_token") + if not token: + token = secrets.token_urlsafe(32) + session["csrf_token"] = token + return token + + +app.jinja_env.globals["csrf_token"] = get_csrf_token diff --git a/core/csrf_guard.py b/core/csrf_guard.py new file mode 100644 index 00000000..1a98e185 --- /dev/null +++ b/core/csrf_guard.py @@ -0,0 +1,39 @@ +"""Vérifie le jeton CSRF (voir core/csrf.py) sur toute requête qui modifie +quelque chose — un before_request de plus, dans le même esprit que +core/auth_guard.py (une seule garde globale plutôt que de toucher aux +~90 routes existantes une par une). + +Toute l'appli soumet ses formulaires ET ses fetch() via une seule voie : +pjax.js intercepte CHAQUE
interne et le transforme lui-même en +fetch() (voir static/pjax.js) — aucun formulaire de ce moteur ne fait donc +jamais de vraie soumission HTML native. static/csrf_fetch.js patche +window.fetch une seule fois pour y ajouter automatiquement l'en-tête +X-CSRFToken sur toute requête non-GET de l'appli (celles de pjax.js +comprises) : pas besoin de modifier individuellement les nombreux appels +fetch() déjà écrits à la main dans screen_edit.html/game_dashboard.html/ +play.html.""" +from flask import abort, request, session + +from .flask_app import app + +_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} + + +@app.before_request +def _verify_csrf_token(): + if request.method in _SAFE_METHODS: + return None + if app.config.get("TESTING"): + # Les tests appellent les routes directement via le client de + # test Flask (de vraies requêtes HTTP, mais jamais passées par le + # navigateur/static/csrf_fetch.js qui pose l'en-tête) — même + # convention que Flask-WTF (WTF_CSRF_ENABLED désactivé en test). + # Ce garde a ses PROPRES tests dédiés (test_csrf.py), qui + # réactivent la vérification explicitement pour la mettre à + # l'épreuve. + return None + submitted = request.headers.get("X-CSRFToken") or request.form.get("csrf_token") + expected = session.get("csrf_token") + if not expected or not submitted or submitted != expected: + abort(400, description="Jeton de sécurité manquant ou expiré — recharge la page et réessaie.") + 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/core/recovery_codes_flash.py b/core/recovery_codes_flash.py new file mode 100644 index 00000000..02e435ca --- /dev/null +++ b/core/recovery_codes_flash.py @@ -0,0 +1,15 @@ +"""Affiche UNE SEULE FOIS les codes de récupération 2FA (voir +auth/recovery_codes.py et routes/auth/register_2fa.py) : posés en session +au moment de la redirection qui suit leur génération, puis consommés +(session.pop) dès le tout premier rendu de base.html qui suit — jamais +revus après ce rendu, comme un message flash à usage unique.""" +from flask import session + +from .flask_app import app + + +def pop_recovery_codes(): + return session.pop("recovery_codes_to_show", None) + + +app.jinja_env.globals["pop_recovery_codes"] = pop_recovery_codes 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/docker-compose.prod.yml b/docker-compose.prod.yml index 9ee4600c..2dcc3a25 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -6,6 +6,16 @@ services: - "${PORT:-5050}:5050" volumes: - forge_projects:/app/projects + environment: + # Réinitialisation de mot de passe par email (voir auth/send_email.py) + # — laissés vides ici tant qu'ils ne sont pas définis dans le .env du + # serveur : l'appli continue de fonctionner sans, l'envoi d'email + # échoue juste silencieusement côté utilisateur (logué côté serveur). + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM: ${SMTP_FROM:-} volumes: forge_projects: diff --git a/filters/element_style_filter.py b/filters/element_style_filter.py index daf9cbe9..c824ba82 100644 --- a/filters/element_style_filter.py +++ b/filters/element_style_filter.py @@ -20,25 +20,47 @@ def _element_style(el): Bug corrigé : le widget "superposition" (render_overlay.py) ignore déjà x/y/width/height et pose lui-même position:fixed; inset:0; z-index:9999 sur SA PROPRE balise — mais tant que CE cadre-ci gardait quand même - "position:absolute; z-index:{z_index}" (le z-index de sa place dans le - canevas, souvent petit), il devenait un élément positionné avec z-index - explicite, donc un NOUVEAU contexte d'empilement CSS — le z-index:9999 - posé plus profond ne se comparait alors plus qu'AU SEIN de ce contexte, - et perdait face au z-index (plus grand) d'un frère ajouté après lui sur - le canevas, qui se retrouvait affiché PAR-DESSUS le dialogue censé tout - couvrir. En ne posant ICI aucune position/z-index pour ce widget (sa - place dans le flux normal, invisible puisque son contenu est en - position:fixed de toute façon), plus aucun contexte d'empilement n'est - créé à ce niveau : le z-index:9999 se compare alors directement aux - autres éléments de l'écran, et gagne toujours.""" - if el.get("widget") == "superposition": - return "position:static;" - return "; ".join([ + "z-index:{z_index}" (le z-index de sa place dans le canevas, souvent + petit), un élément positionné avec un z-index EXPLICITE (même sur ce + cadre, pas sur son contenu) devient un NOUVEAU contexte d'empilement + CSS — le z-index:9999 posé plus profond ne se comparait alors plus + qu'AU SEIN de ce contexte, et perdait face au z-index (plus grand) d'un + frère ajouté après lui sur le canevas, qui se retrouvait affiché + PAR-DESSUS le dialogue censé tout couvrir. + + Première correction tentée (retirer aussi position/left/top/width/ + height ici) : régression dans l'ÉDITEUR — ce même cadre (.canvasElement) + sert aussi de prise pour glisser-déposer/redimensionner CE widget dans + le canevas (voir screen_edit.html), dont le calcul se base sur ses + dimensions réelles ; sans position:absolute + left/top/width/height, ce + cadre s'effondre à 0×0 (son contenu réel est en position:fixed, donc + hors flux), le calcul division par zéro produit NaN, et + JSON.stringify(NaN) donne "null" — element_geometry.py plantait alors + sur float(None). left/top/width/height restent donc posés comme pour + n'importe quel widget (l'éditeur continue de fonctionner normalement) : + SEUL le z-index est omis pour ce widget précis. position:absolute SANS + z-index explicite (donc z-index:auto) ne crée PAS de nouveau contexte + d'empilement — le z-index:9999 posé plus profond se compare alors + directement aux autres éléments de l'écran, et gagne toujours. + + Même omission pour un exemplaire (element_type_id) d'un élément de jeu + dont le modèle N'EST QU'une superposition (marqué "_overlay_only_instance" + par list_elements.py) : son propre widget est "conteneur" par défaut + (add_element.py), pas "superposition", mais son cadre entoure quand + même une superposition rendue à l'intérieur (voir + render_element_html.py/_render_element_type_children) — exactement le + même risque de contexte d'empilement piégeant.""" + exempt_from_z_index = ( + el.get("widget") == "superposition" or el.get("_overlay_only_instance") + ) + parts = [ "position:absolute", f"left:{el['x']}%", f"top:{el['y']}%", f"width:{el['width']}%", f"height:{el['height']}%", - f"z-index:{el['z_index']}", - ]) + ";" + ] + if not exempt_from_z_index: + parts.append(f"z-index:{el['z_index']}") + return "; ".join(parts) + ";" def _element_transform_style(el): 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..1c6a0c1a --- /dev/null +++ b/routes/auth/__init__.py @@ -0,0 +1 @@ +from . import register, register_2fa, login, login_2fa, logout, forgot_password, reset_password, profile diff --git a/routes/auth/forgot_password.py b/routes/auth/forgot_password.py new file mode 100644 index 00000000..f9da5f36 --- /dev/null +++ b/routes/auth/forgot_password.py @@ -0,0 +1,29 @@ +from flask import current_app, redirect, render_template, request, session, url_for + +import auth + +from core.flask_app import app + + +@app.route("/forgot-password", methods=["GET", "POST"]) +def forgot_password(): + if session.get("user_id"): + return redirect(url_for("index")) + sent = False + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + user = auth.get_user_by_email(email) + if user: + token = auth.create_password_reset_token(user["id"]) + reset_url = url_for("reset_password", token=token, _external=True) + try: + auth.send_password_reset_email(user["email"], reset_url) + except auth.EmailNotConfiguredError: + # Jamais révélé à l'utilisateur (voir le message générique + # ci-dessous) : seul l'admin qui lit les logs serveur doit + # savoir que le SMTP n'est pas encore configuré. + current_app.logger.exception("Email de réinitialisation non envoyé : SMTP non configuré.") + # Même message que le compte existe ou non, pour ne jamais + # permettre de deviner quelles adresses sont déjà inscrites. + sent = True + return render_template("auth/forgot_password.html", sent=sent) diff --git a/routes/auth/login.py b/routes/auth/login.py new file mode 100644 index 00000000..a5ae1116 --- /dev/null +++ b/routes/auth/login.py @@ -0,0 +1,43 @@ +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) + # Anti-bruteforce (voir auth/rate_limit.py) : vérifié AVANT le mot + # de passe lui-même — un compte verrouillé ne doit même pas + # apprendre si le mot de passe tapé était le bon. + locked_seconds = auth.seconds_locked_remaining(user) if user else 0 + if locked_seconds: + error = auth.lockout_message(locked_seconds) + elif not user or not auth.verify_password(user, password): + if user: + just_locked_minutes = auth.record_failed_attempt(user["id"]) + if just_locked_minutes: + error = auth.lockout_message(just_locked_minutes * 60) + if not error: + 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..c5006a9b --- /dev/null +++ b/routes/auth/login_2fa.py @@ -0,0 +1,56 @@ +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) 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/profile.py b/routes/auth/profile.py new file mode 100644 index 00000000..223bef36 --- /dev/null +++ b/routes/auth/profile.py @@ -0,0 +1,77 @@ +import os +import shutil + +from flask import g, redirect, render_template, request, session, url_for + +import auth +import db + +from core.flask_app import app + + +def _render(error=None, success=None): + user = auth.get_user_by_id(g.current_user["id"]) + return render_template( + "auth/profile.html", user=user, error=error, success=success, + min_score_required=auth.MIN_SCORE_REQUIRED, + ) + + +@app.route("/profile", methods=["GET"]) +def profile(): + return _render() + + +@app.route("/profile/update-name", methods=["POST"]) +def profile_update_name(): + nom = request.form.get("nom", "").strip() + prenom = request.form.get("prenom", "").strip() + if not nom or not prenom: + return _render(error="Nom et prénom sont requis.") + auth.update_profile(g.current_user["id"], nom, prenom) + return _render(success="Informations mises à jour.") + + +@app.route("/profile/change-password", methods=["POST"]) +def profile_change_password(): + user = g.current_user + current_password = request.form.get("current_password", "") + new_password = request.form.get("new_password", "") + new_password_confirm = request.form.get("new_password_confirm", "") + if not auth.verify_password(user, current_password): + return _render(error="Mot de passe actuel incorrect.") + if new_password != new_password_confirm: + return _render(error="Les nouveaux mots de passe ne correspondent pas.") + if not auth.password_strength(new_password)["valid"]: + return _render(error="Nouveau mot de passe trop faible — respecte les critères affichés.") + auth.set_password(user["id"], new_password) + return _render(success="Mot de passe modifié.") + + +@app.route("/profile/delete", methods=["POST"]) +def profile_delete(): + user = g.current_user + current_password = request.form.get("current_password", "") + confirm_text = request.form.get("confirm_text", "").strip().upper() + if not auth.verify_password(user, current_password): + return _render(error="Mot de passe incorrect — suppression annulée.") + if confirm_text != "SUPPRIMER": + return _render(error='Tape "SUPPRIMER" pour confirmer — suppression annulée.') + if user["role"] == "admin" and auth.count_admins() <= 1: + return _render(error="Impossible de supprimer l'unique compte administrateur.") + + # Un compte "user" ne possède qu'UN SEUL projet, créé automatiquement + # pour lui et impossible à supprimer autrement (voir core/auth_guard.py, + # game_delete bloqué pour ce rôle) — supprimer le compte est donc la + # seule façon de s'en débarrasser aussi, sans quoi il resterait orphelin + # sur le disque sans plus aucun propriétaire. Un "admin" peut posséder + # plusieurs jeux qui ne lui sont pas dédiés de la même façon : ses + # projets restent intacts. + if user["role"] != "admin" and user.get("project_slug"): + game_dir = db.game_dir(user["project_slug"]) + if os.path.isdir(game_dir): + shutil.rmtree(game_dir) + + auth.delete_user(user["id"]) + 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..9c2d32ef --- /dev/null +++ b/routes/auth/register_2fa.py @@ -0,0 +1,59 @@ +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": + locked_seconds = auth.seconds_locked_remaining(user) + if locked_seconds: + error = auth.lockout_message(locked_seconds) + elif not auth.verify_totp(user["totp_secret"], request.form.get("code", "")): + just_locked_minutes = auth.record_failed_attempt(user_id) + error = auth.lockout_message(just_locked_minutes * 60) if just_locked_minutes else \ + "Code invalide — vérifie l'heure de ton téléphone et réessaie." + else: + auth.reset_failed_attempts(user_id) + 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 + # Générés ici, une seule fois, à l'instant même où la 2FA + # devient active (voir auth/recovery_codes.py) : c'est la SEULE + # occasion où ils sont visibles en clair (seul leur hash reste + # en base) — posés en session pour être affichés une unique + # fois par la page suivante (voir core/recovery_codes_flash.py + # et le modal dans base.html), plutôt que d'interrompre ici la + # redirection habituelle vers le tableau de bord/l'accueil. + session["recovery_codes_to_show"] = auth.generate_recovery_codes(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/routes/auth/reset_password.py b/routes/auth/reset_password.py new file mode 100644 index 00000000..9d26c1df --- /dev/null +++ b/routes/auth/reset_password.py @@ -0,0 +1,29 @@ +from flask import redirect, render_template, request, url_for + +import auth + +from core.flask_app import app + + +@app.route("/reset-password/", methods=["GET", "POST"]) +def reset_password(token): + user_id = auth.get_user_id_for_valid_token(token) + if not user_id: + return render_template("auth/reset_password.html", invalid=True) + + error = None + if request.method == "POST": + password = request.form.get("password", "") + password_confirm = request.form.get("password_confirm", "") + if password != password_confirm: + error = "Les mots de passe ne correspondent pas." + elif not auth.password_strength(password)["valid"]: + error = "Mot de passe trop faible — respecte les critères affichés." + else: + auth.set_password(user_id, password) + auth.consume_password_reset_token(token) + return redirect(url_for("login")) + return render_template( + "auth/reset_password.html", invalid=False, error=error, + min_score_required=auth.MIN_SCORE_REQUIRED, + ) diff --git a/screens/clause_list_codec.py b/screens/clause_list_codec.py new file mode 100644 index 00000000..11eded13 --- /dev/null +++ b/screens/clause_list_codec.py @@ -0,0 +1,45 @@ +import json + +# Partagé par control_value.py (widgets) et filter_repeater_rows.py +# (rendering) — module "feuille" sans import de l'un ou l'autre, pour ne +# jamais introduire de dépendance circulaire entre ces deux packages. + + +def decode_clauses(attrs, clauses_attr, combinator_attr, legacy_prefix=None): + """Lit une liste de clauses {"champ", "operateur", "valeur"} depuis un + attribut JSON (clauses_attr). Repli sur l'ANCIEN format à 2 filtres + fixes (legacy_prefix + "_champ"/"_operateur"/"_valeur", et + legacy_prefix + "2_champ"/"2_operateur"/"2_valeur") si clauses_attr est + absent — pour ne rien casser sur les éléments créés avant l'ajout des + clauses illimitées (aucune migration de données nécessaire, la + conversion se fait à la volée, ici, à la lecture). Renvoie toujours + (clauses, combinateur), combinateur valant "et" par défaut.""" + raw = attrs.get(clauses_attr) + clauses = None + if raw: + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + clauses = parsed + except (TypeError, ValueError): + clauses = None + if clauses is None and legacy_prefix: + clauses = [] + for suffix in ("", "2"): + champ = (attrs.get(f"{legacy_prefix}{suffix}_champ") or "").strip() + if champ: + clauses.append({ + "champ": champ, + "operateur": attrs.get(f"{legacy_prefix}{suffix}_operateur") or "egal", + "valeur": attrs.get(f"{legacy_prefix}{suffix}_valeur") or "", + }) + combinateur = attrs.get(combinator_attr) or "et" + return clauses or [], combinateur + + +def encode_clauses(clauses): + """Sérialise une liste de clauses en JSON, en retirant celles sans nom + de champ (une ligne ajoutée puis jamais remplie) — "" si la liste + nettoyée est vide, pour ne rien écrire de superflu en base.""" + cleaned = [c for c in clauses if (c.get("champ") or "").strip()] + return json.dumps(cleaned) if cleaned else "" diff --git a/screens/element_types/is_overlay_only.py b/screens/element_types/is_overlay_only.py new file mode 100644 index 00000000..bc611662 --- /dev/null +++ b/screens/element_types/is_overlay_only.py @@ -0,0 +1,16 @@ +from .load_template_tree import _load_template_tree + + +def _is_overlay_only_element_type(slug, element_type_id): + """True si le modèle d'un élément de jeu N'EST QU'une "Superposition / + boîte de dialogue" (un seul élément de premier niveau, de ce widget) — + utilisé à deux endroits qui doivent s'accorder : render_element_html.py + (court-circuite l'enveloppe générique "conteneur" d'un exemplaire, qui + resterait sinon visible en permanence) et element_style_filter.py (le + cadre .canvasElement/.playElement de cet exemplaire ne doit pas non + plus poser de z-index, pour la même raison qu'une superposition posée + directement — voir son propre commentaire).""" + template_tree = _load_template_tree(slug, element_type_id) + if not template_tree: + return False + return {c.get("widget") for c in template_tree["top"]} == {"superposition"} diff --git a/screens/elements/add_element.py b/screens/elements/add_element.py index 111093ca..e1e99b1b 100644 --- a/screens/elements/add_element.py +++ b/screens/elements/add_element.py @@ -6,6 +6,7 @@ from ..widgets.registry import WIDGETS from ..widgets.widget_meta import widget_meta from ..widgets.default_style_for_widget import default_style_and_attributes from ..element_types.list_element_types import list_element_types +from ..element_types.is_overlay_only import _is_overlay_only_element_type def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None): @@ -39,6 +40,15 @@ def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=Non # proprement) — hors couleurs, qui restent volontairement transparentes/ # sans bordure tant qu'on ne les choisit pas explicitement. attributes, style = default_style_and_attributes(widget_key) + if element_type_id and _is_overlay_only_element_type(slug, int(element_type_id)): + # Un exemplaire dont le modèle N'EST QU'une "Superposition / boîte + # de dialogue" (render_element_html.py décide alors, au rendu, de + # faire porter le masquage à CET EXEMPLAIRE plutôt qu'au modèle) : + # démarre masqué par défaut, exactement comme une superposition + # posée directement (voir default_style_for_widget.py) — sinon un + # dialogue fraîchement déposé couvrirait tout l'écran en jeu dès + # sa pose, avant même qu'une action ne l'affiche. + style["display"] = "none" content = meta.get("default_content", "") width, height = meta.get("w", 30), meta.get("h", 10) diff --git a/screens/elements/list_elements.py b/screens/elements/list_elements.py index cc755cae..f047648c 100644 --- a/screens/elements/list_elements.py +++ b/screens/elements/list_elements.py @@ -3,6 +3,7 @@ import json import db from ..rendering.render_element_html import render_element_html +from ..element_types.is_overlay_only import _is_overlay_only_element_type def list_elements(slug, screen_id, enforce_visibility=False): @@ -75,5 +76,14 @@ def list_elements(slug, screen_id, enforce_visibility=False): children_map.setdefault(d["parent_id"], []).append(d) play_ctx = {"_forge_play_mode": True} if enforce_visibility else None for d in result: + # Un exemplaire de premier niveau d'un élément de jeu dont le + # modèle N'EST QU'une superposition : son cadre de positionnement + # (.canvasElement/.playElement, voir filters/element_style_filter.py) + # ne doit pas non plus poser de z-index, pour la même raison qu'une + # superposition posée directement (sinon elle crée un contexte + # d'empilement CSS qui piège le z-index:9999 de la superposition + # rendue à l'intérieur — voir element_style_filter.py). + if d.get("element_type_id") and not d.get("parent_id"): + d["_overlay_only_instance"] = _is_overlay_only_element_type(slug, d["element_type_id"]) d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx) return result diff --git a/screens/elements/save_element_controls.py b/screens/elements/save_element_controls.py index a5c66542..2abb9587 100644 --- a/screens/elements/save_element_controls.py +++ b/screens/elements/save_element_controls.py @@ -3,6 +3,8 @@ import json import db from ..widgets.widget_meta import widget_meta +from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS +from ..clause_list_codec import encode_clauses from .get_element import get_element @@ -52,6 +54,32 @@ def save_element_controls(slug, element_id, form): toggle_groups[name].add(control["token"]) continue + if ctype == "clause_list": + # Nombre ILLIMITÉ de lignes (voir c_clause_list.py) : un champ + # tableau par colonne ("field_name_champ[]"/"_operateur[]"/ + # "_valeur[]"), reconstituées ici en une liste de clauses par + # position (zip) — même principe que field_name[]/field_type[] + # pour les champs d'un objet (routes/objects/parse_field_rows.py). + champs = form.getlist(field_name + "_champ[]") + operateurs = form.getlist(field_name + "_operateur[]") + valeurs = form.getlist(field_name + "_valeur[]") + clauses = [ + {"champ": champ, "operateur": operateurs[i] if i < len(operateurs) else "egal", + "valeur": valeurs[i] if i < len(valeurs) else ""} + for i, champ in enumerate(champs) + ] + encoded = encode_clauses(clauses) + if encoded: + attributes[control["clauses_attr"]] = encoded + else: + attributes.pop(control["clauses_attr"], None) + combinateur = form.get(field_name + "_combinateur") or "et" + if combinateur == "ou": + attributes[control["combinator_attr"]] = combinateur + else: + attributes.pop(control["combinator_attr"], None) + continue + if ctype == "preset": chosen = form.get(field_name, control.get("default")) opt = next((o for o in control["options"] if o[0] == chosen), None) @@ -61,6 +89,24 @@ def save_element_controls(slug, element_id, form): style.pop(k, None) else: style[k] = v + if ( + control["key"] == "visibilite" and chosen == "visible" + and el["widget"] not in LAYOUT_CAPABLE_WIDGETS + ): + # "Visible" ne touche volontairement pas "display" (voir + # visibility_control.py) : sur un widget qui A un + # réglage "Disposition interne" (LAYOUT_CONTROLS, + # traité avant celui-ci sur le MÊME enregistrement, + # voir widget_meta.py), ce contrôle réaffirme lui-même + # un "display" non-"none" (flex/etc), donc "Visible" n'a + # rien à faire. Mais un widget SANS ce contrôle (ex. + # "superposition") n'a PERSONNE d'autre pour l'effacer : + # un "display:none" laissé par un "Masqué" précédent (ou + # par le réglage par défaut à la création, voir + # default_style_for_widget.py) restait donc bloqué + # masqué pour toujours, quel que soit le nombre de fois + # où "Visible" était ensuite choisi. + style.pop("display", None) continue if ctype == "scale": diff --git a/screens/flow/constants.py b/screens/flow/constants.py index 89050b47..3460ca17 100644 --- a/screens/flow/constants.py +++ b/screens/flow/constants.py @@ -62,6 +62,10 @@ FLOW_NODE_FIELDS = { # qui reste la première clause) + cond_combinator ('et'/'ou') pour savoir # comment les combiner. Absents => comportement legacy (une seule clause). "cond_clauses", "cond_combinator", + # Condition sur une VARIABLE GLOBALE plutôt qu'un champ d'objet — voir + # ensure_flow_schema.py pour le détail des 3 clés (cond_source vaut + # "objet" ou "variable" ; absent => "objet", comportement historique). + "cond_source", "cond_variable", "cond_variable_chemin", "action_type", "target_screen_id", "target_element_id", "element_property", "element_value", "target_definition_id", "target_row_id", "target_field", "data_operation", "data_value", "target_variable", diff --git a/screens/flow/ensure_flow_schema.py b/screens/flow/ensure_flow_schema.py index a796fe91..155f2430 100644 --- a/screens/flow/ensure_flow_schema.py +++ b/screens/flow/ensure_flow_schema.py @@ -56,5 +56,21 @@ def ensure_flow_schema(slug): # cible une variable globale par NOM plutôt qu'un champ d'objet ; # réutilise data_operation/data_value, déjà là pour "modifier_donnee". conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_variable TEXT") + if "cond_source" not in cols: + # Un nœud Condition peut désormais tester soit un champ d'objet + # (comportement historique, cond_source absent ou "objet"), soit + # une VARIABLE GLOBALE (cond_source="variable" — cond_variable : + # son nom ; cond_variable_chemin : chemin optionnel ".champ"/ + # "[index]" si elle est de type objet/tableau, même syntaxe que + # "Chemin dans la variable" de la Condition de visibilité — voir + # visibility_condition_controls.py). Chaque clause de cond_clauses + # (JSON) porte les mêmes 3 clés en plus des siennes. Évalué côté + # CLIENT (templates/play.html, evaluateConditionClause) contre + # gameData.variables, exposé par full_game_payload.py. + conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_source TEXT") + if "cond_variable" not in cols: + conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable TEXT") + if "cond_variable_chemin" not in cols: + conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable_chemin TEXT") conn.commit() conn.close() diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 69fd2894..cad48553 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -67,7 +67,16 @@ def full_game_payload(slug): # modèle est utilisé ailleurs. element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)} + # Instantané des variables globales, pour qu'un nœud Condition puisse en + # tester une côté CLIENT (evaluateConditionClause, templates/play.html) — + # {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté + # SERVEUR au rendu (filter_repeater_rows.py) et n'a jamais eu besoin de + # ça. refreshRuntimeData() récupère un payload entier (donc des + # variables à jour) après toute action qui en modifie une. + variables = {v["name"]: {"value": v["value"], "type": v["type"]} for v in db.list_global_variables(slug)} + return { "screens": payload_screens, "flows": flows, "animations": animations, "element_types": element_types, "data": data, "fields_meta": fields_meta, + "variables": variables, } diff --git a/screens/rendering/filter_repeater_rows.py b/screens/rendering/filter_repeater_rows.py index 6aca7715..8b0d025a 100644 --- a/screens/rendering/filter_repeater_rows.py +++ b/screens/rendering/filter_repeater_rows.py @@ -3,6 +3,8 @@ import re import db +from ..clause_list_codec import decode_clauses + # {{NomDeLObjet.nom_du_champ}} : référence à un AUTRE objet de données, pour # comparer le champ filtré à une valeur qui change en cours de partie (ex: # le niveau atteint) plutôt qu'à une valeur fixe tapée en dur — même @@ -154,13 +156,30 @@ def _compare(actual, operator, expected, field_type): return False -def _apply_one_filter(slug, definition, rows, field_name, operator, raw_value): +def _clause_matches(slug, definition, row, clause): + field_name = (clause.get("champ") or "").strip() if not field_name: - return rows - expected = _resolve_filter_value(slug, raw_value or "") + return True + operator = clause.get("operateur") or "egal" + expected = _resolve_filter_value(slug, clause.get("valeur") or "") field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte") col = _field_column(definition, field_name) - return [r for r in rows if _compare(r.get(col), operator, expected, field_type)] + return _compare(row.get(col), operator, expected, field_type) + + +def filter_rows_by_clauses(slug, definition, rows, clauses, combinator): + """Ne garde, parmi les lignes, que celles qui satisfont la liste de + clauses données (un nombre quelconque), combinées entre elles par ET + (toutes vraies) ou par OU (au moins une vraie) selon `combinator` + ("et"/"ou"). Utilisé aussi bien par le Répéteur de données (clauses + reconstruites depuis _filtre_champ/_filtre2_champ, voir + filter_repeater_rows ci-dessous) que par la "Donnée liée" d'un Texte/ + Titre (clauses illimitées, voir resolve_bound_row.py).""" + if not clauses: + return rows + if combinator == "ou": + return [r for r in rows if any(_clause_matches(slug, definition, r, c) for c in clauses)] + return [r for r in rows if all(_clause_matches(slug, definition, r, c) for c in clauses)] def filter_repeater_rows(slug, definition, rows, attrs): @@ -172,12 +191,5 @@ def filter_repeater_rows(slug, definition, rows, attrs): sur un écran unique") : c'est ce qui permet à un seul écran de n'afficher, par exemple, que le niveau du parcours en cours (2 conditions : le bon parcours ET le bon numéro de niveau) plutôt que tous les niveaux.""" - rows = _apply_one_filter( - slug, definition, rows, - (attrs.get("_filtre_champ") or "").strip(), attrs.get("_filtre_operateur") or "egal", attrs.get("_filtre_valeur"), - ) - rows = _apply_one_filter( - slug, definition, rows, - (attrs.get("_filtre2_champ") or "").strip(), attrs.get("_filtre2_operateur") or "egal", attrs.get("_filtre2_valeur"), - ) - return rows + clauses, combinator = decode_clauses(attrs, "_filtres_json", "_filtre_combinateur", legacy_prefix="_filtre") + return filter_rows_by_clauses(slug, definition, rows, clauses, combinator) diff --git a/screens/rendering/render_element_html.py b/screens/rendering/render_element_html.py index 909e205d..33780232 100644 --- a/screens/rendering/render_element_html.py +++ b/screens/rendering/render_element_html.py @@ -18,6 +18,7 @@ from .render_icone import _render_icone from .resolve_bound_row import _resolve_bound_row_ctx from .visibility_condition import is_element_visible from ..element_types.load_template_tree import _load_template_tree +from ..element_types.is_overlay_only import _is_overlay_only_element_type def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None): @@ -51,8 +52,27 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_ if play_mode and has_visibility_condition and not is_element_visible(slug, attrs_raw, ctx): return f'
' + # "Donnée liée" (DATA_BINDING_CONTROLS, Texte/Titre) : son contenu + # ({{champ}}) dépend de la ligne trouvée par ses conditions au moment du + # rendu — potentiellement via une "Variable globale" en valeur de + # comparaison (voir _resolve_filter_value). Comme "visibilityGated" + # juste au-dessus, un marqueur ("dataBound") reste posé sur le HTML pour + # que refreshRuntimeData() (play.html) sache qu'il faut réévaluer cet + # élément après toute action "Modifier une donnée"/"Modifier une + # variable" — sans lui, changer la variable choisie changeait bien QUELLE + # ligne aurait dû s'afficher (recalculé correctement côté serveur à + # chaque rendu), mais le client ne redemandait jamais le nouveau HTML de + # CET élément précis : le texte affiché restait figé sur son ancienne + # ligne jusqu'au prochain rechargement complet de la page. + has_data_binding = bool(attrs_raw.get("_data_definition_id")) + def _mark(html_str): - return html_str + "" if has_visibility_condition else html_str + markers = "" + if has_visibility_condition: + markers += "" + if has_data_binding: + markers += "" + return html_str + markers bound_ctx = _resolve_bound_row_ctx(slug, attrs_raw) if bound_ctx: @@ -107,9 +127,44 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_ return _mark(f"<{tag} {_attr_string(attrs, style)}>") content = _apply_ctx(el.get("content") or "", ctx) - child_html = _render_element_type_children(el, slug, ctx) if el.get("element_type_id") else None + child_html, is_overlay_template = ( + _render_element_type_children(el, slug, ctx) if el.get("element_type_id") else (None, False) + ) if child_html is None: child_html = _render_children(el, slug, children_map, ctx) + elif is_overlay_template: + # Un exemplaire d'élément de jeu est posé par défaut avec le widget + # générique "conteneur" (add_element.py, "default_widget") — utile + # pour la plupart des modèles, mais QUAND le modèle entier n'est + # qu'une "Superposition / boîte de dialogue", la classe Bulma "box" + # de ce widget (widgets/registry.py "conteneur") lui donnait un + # fond opaque TOUJOURS VISIBLE à l'endroit où l'exemplaire a été + # déposé, alors que la superposition à l'intérieur gère déjà + # entièrement sa propre apparence et son propre masquage + # (position:fixed plein écran, démarre masquée) — vécu comme "un + # conteneur vide apparaît sur la scène, pas la boîte de dialogue" + # (en réalité la boîte de dialogue existe bien, juste masquée comme + # prévu ; c'est le conteneur AUTOUR qui n'aurait jamais dû avoir sa + # propre apparence visuelle). + # + # On ne court-circuite PLUS entièrement cette enveloppe (un essai + # précédent le faisait, en renvoyant `child_html` directement) : + # cette balise porte le SEUL id/data-element-id de CET EXEMPLAIRE + # précis (id=el["id"], distinct de l'id interne du modèle, + # ex. l'overlay du modèle "dialog" garde son propre id 72 alors que + # CET exemplaire est, par ex., 77) — une action "Modifier un + # élément" posée sur LA SCÈNE qui l'accueille (pas sur le modèle + # lui-même) cible cet id d'exemplaire, jamais celui, interne et + # partagé par tous les exemplaires, du modèle. La court-circuiter + # entièrement faisait disparaître cet id du DOM, rendant + # IMPOSSIBLE tout contrôle de la boîte de dialogue depuis la + # logique de la scène qui l'accueille ("j'ai aucun contrôle sur la + # visibilité de la modale depuis la scène"). On garde donc la + # balise (avec son id), mais SANS la classe "box" du widget + # générique — juste une balise transparente, invisible tant + # qu'elle ne porte ni fond ni bordure.""" + attrs.pop("class", None) + return _mark(f"
{child_html}
") if tag in ("ul", "ol"): items = [line.strip() for line in content.split("\n") if line.strip()] @@ -128,15 +183,35 @@ def _render_element_type_children(el, slug, ctx): Répéteur (voir _render_repeater/_load_template_tree). Modifier l'élément de jeu dans son propre éditeur met donc à jour tous ses exemplaires déjà posés, sur n'importe quel écran, sans avoir à les - retoucher un par un. Renvoie None (pas "") si l'élément de jeu n'a + retoucher un par un. Renvoie (None, False) si l'élément de jeu n'a plus de modèle valide, pour que l'appelant retombe sur le rendu générique (d'éventuels enfants en base issus d'une version antérieure de ce mécanisme) plutôt que d'afficher un exemplaire silencieusement - vide.""" + vide. Le second élément renvoyé indique si le modèle N'EST QUE une + superposition (voir l'appelant : dans ce cas précis, l'enveloppe + générique "conteneur" de l'exemplaire ne doit pas avoir sa propre + apparence visuelle).""" template_tree = _load_template_tree(slug, el["element_type_id"]) if not template_tree: - return None - return "".join( + return None, False + is_overlay_only = _is_overlay_only_element_type(slug, el["element_type_id"]) + if is_overlay_only: + # Le masquage de CET EXEMPLAIRE est entièrement délégué à + # l'enveloppe (voir l'appelant : add_element.py la fait démarrer + # masquée par défaut, exactement comme la superposition elle-même) + # — la superposition INTERNE au modèle ne doit donc plus appliquer + # SON PROPRE réglage "Visibilité" une fois posée comme exemplaire + # ailleurs (sinon double masquage : rendre l'enveloppe visible + # depuis la scène qui l'accueille resterait sans aucun effet + # visible tant que la superposition interne, elle, reste "Masqué" + # dans le modèle). On retire donc "display" de sa copie de style + # avant rendu (copie éphémère, jamais réenregistrée) — seul le + # display:flex de base (render_overlay.py) s'applique alors. + overlay_root = dict(template_tree["top"][0]) + overlay_root["style"] = {k: v for k, v in overlay_root["style"].items() if k != "display"} + template_tree["top"][0] = overlay_root + html = "".join( render_element_html(c, slug, template_tree["children_map"], ctx) for c in template_tree["top"] ) + return html, is_overlay_only diff --git a/screens/rendering/render_overlay.py b/screens/rendering/render_overlay.py index cff5569d..a0f1342a 100644 --- a/screens/rendering/render_overlay.py +++ b/screens/rendering/render_overlay.py @@ -7,32 +7,61 @@ from .render_children import _render_children def _render_overlay(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None): """3.4 (Confort) — overlay/modale réutilisable : une boîte de dialogue - prête à l'emploi, par-dessus TOUT le reste de l'écran, fermeture - manuelle uniquement (aucun clic-en-dehors-pour-fermer volontairement, - conformément au manque documenté). Contrairement aux autres widgets, sa - position ne dépend PAS de x/y/width/height (glissé-déposé sur le - canevas) : `position:fixed; inset:0` la fait toujours couvrir tout - l'écran, quel que soit l'endroit où elle a été posée dans l'éditeur — - seul un voile semi-transparent + une boîte centrée, contenant les - éléments posés à l'intérieur (comme un conteneur normal). + prête à l'emploi, par-dessus TOUT le reste de l'écran EN MODE JOUABLE + UNIQUEMENT (`position:fixed; inset:0` + voile semi-transparent, ignore + x/y/width/height) — fermeture manuelle uniquement (aucun + clic-en-dehors-pour-fermer volontairement, conformément au manque + documenté). - Ouverture/fermeture : PAS de mécanisme dédié — elle réutilise l'action - existante "Modifier un élément → Visibilité" (masquer/rendre visible), - exactement comme n'importe quel autre élément. C'est pour ça qu'on - fusionne le style de positionnement fixe ci-dessous AVEC le style normal - de l'élément (_style_string) : si l'élément est réglé "Masqué" dans ses - propriétés, ou si une action "Modifier un élément" le cache plus tard, - ce masquage continue de fonctionner normalement.""" + Dans l'ÉDITEUR (ctx sans "_forge_play_mode" — posé uniquement en mode + jouable, voir list_elements.py), ce widget se comporte comme un + CONTENEUR NORMAL : position/taille selon x/y/width/height comme + n'importe quel widget, respect normal de son réglage "Visibilité" + (masqué = invisible dans l'éditeur aussi, comme tout autre widget), + AUCUN voile plein écran. Deux essais précédents corrigés à partir des + retours utilisateur : + - Fond assombri visible pendant l'édition ("je veux que rien ne soit + assombri, l'assombrissement ne se fait que quand la scène est + jouée") : le voile (background:rgba(...)) ne fait donc plus partie + du style de base, il n'est ajouté qu'en mode jouable. + - Visibilité forcée en permanence dans l'éditeur ("je veux avoir la + main sur la visibilité, sinon elle s'affiche toujours sur la scène + et court-circuite ma logique") : le forçage display:flex a donc été + retiré — l'éditeur respecte de nouveau fidèlement le réglage + "Visibilité" (masqué par défaut à la création, pour ne plus couvrir + tout l'écran EN JEU dès qu'on la pose — sans plus aucun rapport avec + l'éditeur, qui ne couvre plus jamais rien). + + Ouverture/fermeture (en JEU) : PAS de mécanisme dédié — elle réutilise + l'action existante "Modifier un élément → Visibilité" (masquer/rendre + visible), exactement comme n'importe quel autre élément. + + Classes Bulma ("modal is-active" / "box") posées en PLUS du style + inline, jamais à sa place, et UNIQUEMENT en mode jouable (voir + ci-dessus) : tout le positionnement/masquage critique (position:fixed, + z-index, display) reste en inline, qui gagne toujours sur une règle de + classe — si Bulma (chargé depuis un CDN, voir play.html) ne se charge + pas (hors-ligne), la boîte de dialogue continue de fonctionner + exactement pareil. Pas de ".modal-background" séparé : le voile + semi-transparent est déjà posé en inline sur cette même balise + (background:rgba(...)) — un second calque tout aussi transparent + par-dessus n'ajouterait rien.""" + is_play_mode = bool(ctx and ctx.get("_forge_play_mode")) attrs_raw = el.get("attributes") or {} box_color = attrs_raw.get("_couleur_boite") or "#1f2430" radius = attrs_raw.get("_arrondi") or "12" attrs = _visible_attrs(el, meta, ctx) - base_overlay_style = ( - "position:fixed; inset:0; z-index:9999; display:flex; " - "align-items:center; justify-content:center; background:rgba(0,0,0,0.6);" - ) - style = base_overlay_style + " " + _style_string(el, parent_flex_direction=parent_flex_direction) + if is_play_mode: + base_overlay_style = ( + "position:fixed; inset:0; z-index:9999; display:flex; " + "align-items:center; justify-content:center; background:rgba(0,0,0,0.6);" + ) + style = base_overlay_style + " " + _style_string(el, parent_flex_direction=parent_flex_direction) + wrapper_class = "modal is-active" + else: + style = _style_string(el, parent_flex_direction=parent_flex_direction) + wrapper_class = "" attr_parts = [f'style="{html_lib.escape(style)}"'] for k, v in attrs.items(): @@ -49,11 +78,23 @@ def _render_overlay(el, meta, slug, children_map=None, ctx=None, parent_flex_dir # bord à bord peu lisible comme dialogue — 560px reste une largeur # de boîte de dialogue confortable, tout en retombant sur 90% sur un # écran de jeu étroit (mobile/portrait) pour ne jamais déborder. - "padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;" + # color:#e8eaf0 : la classe Bulma ".box" (ajoutée ci-dessous) impose + # elle-même une couleur de texte SOMBRE (pensée pour un fond blanc + # par défaut) — comme aucun widget ne fige de couleur de texte à sa + # création (default_style_for_widget.py), un titre/texte posé dans + # la boîte SANS couleur personnalisée héritait de ce gris sombre + # imposé par Bulma, invisible sur le fond sombre par défaut de cette + # boîte de dialogue (texte "présent mais invisible", pas de bug côté + # utilisateur). On redonne donc ici une couleur claire par défaut, + # que Bulma ne peut plus écraser (élément le plus proche gagne) — + # un texte/titre qui personnalise sa propre couleur reste bien sûr + # prioritaire sur celle-ci. + "color:#e8eaf0; padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;" ) child_html = _render_children(el, slug, children_map, ctx) + wrapper_class_attr = f'class="{wrapper_class}" ' if wrapper_class else "" return ( - f'
' - f'
{child_html}
' + f'
' + f'
{child_html}
' f"
" ) diff --git a/screens/rendering/resolve_bound_row.py b/screens/rendering/resolve_bound_row.py index 597cb2e8..79b9e37a 100644 --- a/screens/rendering/resolve_bound_row.py +++ b/screens/rendering/resolve_bound_row.py @@ -1,13 +1,17 @@ import db -from .filter_repeater_rows import filter_repeater_rows, _field_column +from .filter_repeater_rows import filter_rows_by_clauses, _field_column +from ..clause_list_codec import decode_clauses def _resolve_bound_row_ctx(slug, attrs): """Si cet élément est lié à un objet de données (attr:_data_definition_id — réglage "Lier à un objet de données" des widgets Texte/Titre, voir - DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux 1 ou 2 - filtres réglés (_data_filtre*/_data_filtre2*) — même moteur que le + DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux conditions + réglées (un nombre quelconque, combinées par ET/OU — attr: + _data_filtres_json/_data_filtre_combinateur, avec repli sur l'ancien + format à 2 filtres fixes _data_filtre*/_data_filtre2* pour les éléments + créés avant l'ajout des clauses illimitées) — même moteur que le filtre du Répéteur de données, voir filter_repeater_rows.py — et renvoie ses champs sous forme de {nom_du_champ: valeur en texte}, exploitable par {{champ}} dans le contenu. Permet d'afficher UNE seule @@ -24,14 +28,8 @@ def _resolve_bound_row_ctx(slug, attrs): if not definition: return None rows = db.list_rows(slug, definition) - rows = filter_repeater_rows(slug, definition, rows, { - "_filtre_champ": attrs.get("_data_filtre_champ"), - "_filtre_operateur": attrs.get("_data_filtre_operateur"), - "_filtre_valeur": attrs.get("_data_filtre_valeur"), - "_filtre2_champ": attrs.get("_data_filtre2_champ"), - "_filtre2_operateur": attrs.get("_data_filtre2_operateur"), - "_filtre2_valeur": attrs.get("_data_filtre2_valeur"), - }) + clauses, combinator = decode_clauses(attrs, "_data_filtres_json", "_data_filtre_combinateur", legacy_prefix="_data_filtre") + rows = filter_rows_by_clauses(slug, definition, rows, clauses, combinator) if not rows: return None row = rows[0] diff --git a/screens/widgets/control_groups/data_binding_controls.py b/screens/widgets/control_groups/data_binding_controls.py index 8d8334e1..adbe674f 100644 --- a/screens/widgets/control_groups/data_binding_controls.py +++ b/screens/widgets/control_groups/data_binding_controls.py @@ -1,26 +1,20 @@ -from ..controls.c_text import c_text from ..controls.c_select import c_select +from ..controls.c_clause_list import c_clause_list from ...flow.constants import CONDITION_OPERATORS # Permet à un Texte/Titre d'afficher directement la valeur d'un champ d'un # AUTRE objet de données, sans avoir besoin de le poser dans un Répéteur — # utile pour une valeur unique calculée (ex: l'objectif du niveau en cours) -# plutôt qu'une liste. La ligne affichée est celle qui correspond aux 1 ou 2 -# filtres réglés ci-dessous (mêmes réglages, même moteur que ceux du -# Répéteur de données — voir filter_repeater_rows.py) ; sans filtre, c'est la -# ligne la plus récente de l'objet choisi (convention déjà utilisée par la -# Jauge pour un objet "état de partie" qui ne garde qu'une seule ligne). +# plutôt qu'une liste. La ligne affichée est celle qui correspond aux +# conditions réglées ci-dessous (un nombre quelconque, combinées par ET/OU — +# mêmes clauses, même moteur que le Répéteur de données, voir +# filter_repeater_rows.py) ; sans condition, c'est la ligne la plus récente +# de l'objet choisi (convention déjà utilisée par la Jauge pour un objet +# "état de partie" qui ne garde qu'une seule ligne). DATA_BINDING_CONTROLS = [ c_select("data_definition_id", "Lier à un objet de données (pour {{champ}} ci-dessus)", "attr:_data_definition_id", [("", "(sélectionner un objet)")], default="", group="Donnée liée"), - c_select("data_filtre_champ", "Trouver la ligne où ce champ", - "attr:_data_filtre_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"), - c_select("data_filtre_operateur", "...est", "attr:_data_filtre_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"), - c_text("data_filtre_valeur", "...cette valeur", - "attr:_data_filtre_valeur", placeholder="ex. 3", default="", group="Donnée liée"), - c_select("data_filtre2_champ", "ET (optionnel) : ce 2ᵉ champ", - "attr:_data_filtre2_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"), - c_select("data_filtre2_operateur", "...est", "attr:_data_filtre2_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"), - c_text("data_filtre2_valeur", "...cette valeur", - "attr:_data_filtre2_valeur", placeholder="ex. 1", default="", group="Donnée liée"), + c_clause_list("data_filtres", "Trouver la ligne où...", + clauses_attr="_data_filtres_json", combinator_attr="_data_filtre_combinateur", + operators=CONDITION_OPERATORS, legacy_prefix="_data_filtre", group="Donnée liée"), ] diff --git a/screens/widgets/control_value.py b/screens/widgets/control_value.py index b20aa867..ab838c87 100644 --- a/screens/widgets/control_value.py +++ b/screens/widgets/control_value.py @@ -1,5 +1,7 @@ import re +from ..clause_list_codec import decode_clauses + def _control_value(el, control): """Lit la valeur actuelle d'un réglage à partir de l'élément stocké @@ -7,8 +9,34 @@ def _control_value(el, control): ctype = control["type"] style = el.get("style") or {} + if ctype == "clause_list": + attrs = el.get("attributes") or {} + clauses, combinateur = decode_clauses( + attrs, control["clauses_attr"], control["combinator_attr"], + legacy_prefix=control.get("legacy_prefix"), + ) + return {"clauses": clauses, "combinateur": combinateur} + if ctype == "preset": - for key, _label, props in control["options"]: + # Trié par nombre de propriétés NON VIDES exigées, décroissant : une + # option comme "Visible normalement" (visibility_control.py), dont + # les critères ({"visibility": ""}) ne portent QUE sur "visibility" + # (par choix assumé, pour ne jamais toucher "display" — voir son + # commentaire), matchait sinon TOUJOURS en premier dès que + # "visibility" est absent, MÊME quand "display:none" (posé par + # "Masqué", un critère plus spécifique à 1 propriété non vide contre + # 0 pour "Visible") est également présent -- le panneau affichait + # alors "Visible normalement" sélectionné sur un élément en réalité + # masqué, et comme un
+ +
+ +
+ +

Retour à la connexion

+ {% endif %} + + +{% endblock %} diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 00000000..517f1e43 --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}Connexion — Forge Engine{% endblock %} +{% block content %} +
+
+

Connexion

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

Mot de passe oublié ?

+

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..fa6a8e82 --- /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é, ou un de tes codes de récupération si tu n'y as plus accès.

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ +
+
+
+ +
+
+
+
+{% endblock %} diff --git a/templates/auth/profile.html b/templates/auth/profile.html new file mode 100644 index 00000000..0869d285 --- /dev/null +++ b/templates/auth/profile.html @@ -0,0 +1,121 @@ +{% extends "base.html" %} +{% block title %}Mon profil — Forge Engine{% endblock %} +{% block content %} +
+
+

Mon profil

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

Informations

+

Adresse email : {{ user.email }} ({{ 'compte administrateur' if user.role == 'admin' else 'compte utilisateur' }})

+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+

Changer de mot de passe

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

Supprimer mon compte

+

+ {% if user.role != 'admin' %} + Action définitive et irréversible : ton compte ET ton jeu ({{ user.project_slug }}) seront supprimés, sans possibilité de récupération. + {% else %} + Action définitive et irréversible : ton compte sera supprimé (tes jeux existants ne sont pas touchés). + {% 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/auth/reset_password.html b/templates/auth/reset_password.html new file mode 100644 index 00000000..2f202b11 --- /dev/null +++ b/templates/auth/reset_password.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}Nouveau mot de passe — Forge Engine{% endblock %} +{% block content %} +
+
+

Choisir un nouveau mot de passe

+ + {% if invalid %} +
Ce lien de réinitialisation est invalide ou a expiré.
+

Redemander un lien

+ {% else %} + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ +
+
+ +
+
+
    +
  • Au moins 8 caractères
  • +
  • Une majuscule
  • +
  • Une minuscule
  • +
  • Un chiffre
  • +
  • Un caractère spécial (ex. ! ? # -)
  • +
+
+ +
+ +
+
+ +
+ +
+
+ {% endif %} +
+
+ +{% if not invalid %} + +{% endif %} +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 63ac44dc..63130863 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,6 +6,8 @@ {% block title %}Forge Engine{% endblock %} + + {% block extra_head %}{% endblock %} @@ -20,11 +22,35 @@ ne rechargeait pas la page en entier (Ctrl+F5), et inversement laissée en place en revenant sur l'accueil (liste des jeux) où elle n'a pourtant rien à faire. --> +{% set recovery_codes = pop_recovery_codes() %} +{% if recovery_codes %} + +{% endif %}
{% if game is defined and game %} +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+ + +
+
+ +
diff --git a/templates/play.html b/templates/play.html index b49828f0..cd2c7e82 100644 --- a/templates/play.html +++ b/templates/play.html @@ -5,6 +5,8 @@ {{ game.name }} — Jouer + +