Ajoute l'authentification : inscription, mot de passe fort, 2FA obligatoire, isolation par utilisateur
Première des deux grandes fonctionnalités demandées (authentification d'abord, export HTML/CSS/JS autonome ensuite) : - Inscription (nom, prénom, email UNIQUE, mot de passe) avec schéma visuel du mot de passe (jauge + liste de critères qui passent au vert en direct — auth/password_strength.py, mêmes règles vérifiées côté serveur qu'affichées côté client). - Double authentification (TOTP, compatible Google Authenticator/Authy) OBLIGATOIRE dès l'inscription : QR code (SVG, sans dépendance Pillow) à scanner puis code à confirmer avant que le compte soit utilisable — voir auth/create_user.py (totp_confirmed) et routes/auth/register_2fa.py. - Connexion en 2 temps (mot de passe puis code TOTP), déconnexion. - Isolation par utilisateur : un compte "user" est limité à un SEUL projet, dont le dossier est nommé d'après son adresse email (slugifiée) et créé automatiquement dès la 2FA confirmée — aucune page de gestion multi-jeux pour lui (redirigé directement vers son propre tableau de bord). Le rôle "admin" reste illimité, comme le moteur l'a toujours été (le TOUT PREMIER compte jamais créé sur une base de comptes vide devient automatiquement admin — voir auth/is_first_user.py — pas de mot de passe par défaut à faire circuler : s'inscrire en premier suffit). Un compte "user" ne peut pas non plus supprimer son unique projet (aucune façon d'en recréer un ensuite). - Garde d'accès globale (core/auth_guard.py, un seul before_request) : toute page exige une connexion, sans avoir touché individuellement aux ~80 routes déjà existantes du moteur. tests/conftest.py isole complètement les tests de la vraie base de comptes (FORGE_USERS_DB_PATH/FORGE_SECRET_KEY_PATH vers un dossier temporaire propre à la session de tests) et authentifie automatiquement la fixture `client` partagée en tant que compte admin de test — les 155 tests déjà existants continuent de passer SANS AUCUNE modification de leur côté, exactement comme avant l'authentification. 10 nouveaux tests dédiés (tests/test_auth.py) : inscription/mots de passe/2FA/connexion/ isolation par projet/blocage de suppression, vérifiés en conditions réelles (vrai client de test Flask, vraie base SQLite, vrais codes TOTP calculés avec pyotp). 165 tests au total, tous au vert. Nouvelles dépendances : pyotp, qrcode (requirements.txt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ed4dd78dad
commit
d90abc827b
@@ -17,6 +17,10 @@ Bug/
|
|||||||
# Jeux créés par les utilisateurs (données runtime, pas du code)
|
# Jeux créés par les utilisateurs (données runtime, pas du code)
|
||||||
projects/
|
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
|
# OS / éditeurs
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import webbrowser
|
|||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja
|
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja
|
||||||
import routes # noqa: F401 - enregistre toutes les routes sur `app`
|
import routes # noqa: F401 - enregistre toutes les routes sur `app`
|
||||||
|
from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes)
|
||||||
|
|
||||||
|
|
||||||
def _open_browser():
|
def _open_browser():
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""auth — comptes utilisateurs, connexion, 2FA (TOTP) et isolation par
|
||||||
|
utilisateur d'un SEUL projet (sauf le rôle "admin", illimité) — voir
|
||||||
|
create_user.py pour le détail des règles. Base SQLite entièrement séparée
|
||||||
|
de db/ (une base par JEU) : ces comptes n'appartiennent à aucun jeu, ils
|
||||||
|
en POSSÈDENT un (project_slug)."""
|
||||||
|
|
||||||
|
from .connection import users_db_path
|
||||||
|
from .ensure_schema import ensure_users_schema
|
||||||
|
from .is_first_user import is_first_user
|
||||||
|
from .create_user import create_user, UserCreationError
|
||||||
|
from .get_user_by_email import get_user_by_email
|
||||||
|
from .get_user_by_id import get_user_by_id
|
||||||
|
from .verify_password import verify_password
|
||||||
|
from .confirm_totp import confirm_totp
|
||||||
|
from .verify_totp import verify_totp
|
||||||
|
from .set_project_slug import set_project_slug
|
||||||
|
from .password_strength import password_strength, MIN_SCORE_REQUIRED
|
||||||
|
from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"users_db_path", "ensure_users_schema", "is_first_user",
|
||||||
|
"create_user", "UserCreationError",
|
||||||
|
"get_user_by_email", "get_user_by_id",
|
||||||
|
"verify_password", "confirm_totp", "verify_totp", "set_project_slug",
|
||||||
|
"password_strength", "MIN_SCORE_REQUIRED",
|
||||||
|
"totp_provisioning_uri", "totp_qrcode_svg",
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from .connection import connect
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_users_schema():
|
||||||
|
conn = connect()
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS _users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
nom TEXT NOT NULL,
|
||||||
|
prenom TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
totp_secret TEXT NOT NULL,
|
||||||
|
totp_confirmed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
project_slug TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import io
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
import qrcode
|
||||||
|
import qrcode.image.svg
|
||||||
|
|
||||||
|
|
||||||
|
def totp_provisioning_uri(secret, email):
|
||||||
|
return pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="Forge Engine")
|
||||||
|
|
||||||
|
|
||||||
|
def totp_qrcode_svg(secret, email):
|
||||||
|
"""SVG (pas PNG) : la variante "image factory" par défaut de qrcode a
|
||||||
|
besoin de Pillow pour produire un PNG — SvgImage, elle, est du pur
|
||||||
|
Python, sans dépendance supplémentaire à installer juste pour un QR
|
||||||
|
code affiché une seule fois par compte, à l'inscription."""
|
||||||
|
uri = totp_provisioning_uri(secret, email)
|
||||||
|
img = qrcode.make(uri, image_factory=qrcode.image.svg.SvgImage)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf)
|
||||||
|
return buf.getvalue().decode("utf-8")
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from werkzeug.security import check_password_hash
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(user, password):
|
||||||
|
return bool(user) and check_password_hash(user["password_hash"], password or "")
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import pyotp
|
||||||
|
|
||||||
|
|
||||||
|
def verify_totp(secret, code):
|
||||||
|
"""valid_window=1 : tolère un léger décalage d'horloge entre le
|
||||||
|
serveur et le téléphone (accepte aussi le code de la période
|
||||||
|
précédente/suivante, ±30s) — sans quoi une horloge un peu désynchronisée
|
||||||
|
suffirait à rendre la 2FA injouable."""
|
||||||
|
code = (code or "").strip()
|
||||||
|
if not code:
|
||||||
|
return False
|
||||||
|
return pyotp.TOTP(secret).verify(code, valid_window=1)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Garde d'accès globale — connexion obligatoire pour tout le moteur, et
|
||||||
|
isolation par utilisateur d'un SEUL projet (sauf le rôle "admin",
|
||||||
|
illimité comme avant l'authentification). Un seul before_request plutôt
|
||||||
|
qu'un décorateur à poser sur chacune des ~80 routes existantes : moins de
|
||||||
|
risque d'en oublier une, et aucun fichier de routes existant n'a besoin
|
||||||
|
d'être modifié.
|
||||||
|
|
||||||
|
Cet import doit avoir lieu APRÈS `import routes` (voir app.py/conftest.py)
|
||||||
|
pour que `app.url_map` connaisse déjà toutes les routes au moment où ce
|
||||||
|
module tente de résoudre request.endpoint — en pratique sans importance
|
||||||
|
ici (la résolution se fait à la requête, pas à l'import), mais gardé pour
|
||||||
|
rester cohérent avec l'ordre d'import du reste du moteur."""
|
||||||
|
from flask import g, redirect, request, session, url_for, abort
|
||||||
|
|
||||||
|
import auth
|
||||||
|
|
||||||
|
from .flask_app import app
|
||||||
|
|
||||||
|
# Endpoints accessibles SANS être connecté — tout le reste exige une
|
||||||
|
# session valide. "static" (CSS/JS/images) doit rester public : la page
|
||||||
|
# de connexion elle-même en a besoin pour s'afficher.
|
||||||
|
_PUBLIC_ENDPOINTS = {
|
||||||
|
"static", "login", "login_2fa", "register", "register_2fa", "logout",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _require_login_and_enforce_project_isolation():
|
||||||
|
endpoint = request.endpoint
|
||||||
|
if endpoint is None or endpoint in _PUBLIC_ENDPOINTS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_id = session.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
return redirect(url_for("login", next=request.path))
|
||||||
|
|
||||||
|
user = auth.get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
# Compte supprimé/introuvable depuis la dernière requête : la
|
||||||
|
# session ne doit jamais rester "connectée" dans le vide.
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
g.current_user = user
|
||||||
|
|
||||||
|
# Un compte "user" n'a accès qu'à SON SEUL projet (project_slug) — un
|
||||||
|
# "admin" reste illimité, exactement comme avant l'authentification.
|
||||||
|
# Les routes de gestion multi-jeux (page d'accueil, "+ Nouveau jeu")
|
||||||
|
# n'ont pas leur place pour un compte à projet unique : redirigées
|
||||||
|
# directement vers son propre tableau de bord plutôt qu'un 403 sec.
|
||||||
|
if user["role"] != "admin":
|
||||||
|
if endpoint in ("index", "games_new") and user.get("project_slug"):
|
||||||
|
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
|
||||||
|
slug = request.view_args.get("slug") if request.view_args else None
|
||||||
|
if slug is not None and slug != user.get("project_slug"):
|
||||||
|
abort(403)
|
||||||
|
if endpoint == "game_delete":
|
||||||
|
# Un compte "user" n'a qu'UN SEUL projet, et aucun moyen d'en
|
||||||
|
# recréer un une fois supprimé (games_new le renvoie toujours
|
||||||
|
# vers project_slug, même si le dossier n'existe plus) —
|
||||||
|
# bloqué plutôt que de risquer de le laisser sans projet du
|
||||||
|
# tout. Un admin, lui, peut toujours supprimer ses jeux comme
|
||||||
|
# avant (aucune restriction ci-dessus pour ce rôle).
|
||||||
|
abort(403)
|
||||||
|
return None
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
@@ -7,3 +8,27 @@ _TEMPLATE_FOLDER = os.path.join(_BASE_DIR, "templates")
|
|||||||
_STATIC_FOLDER = os.path.join(_BASE_DIR, "static")
|
_STATIC_FOLDER = os.path.join(_BASE_DIR, "static")
|
||||||
|
|
||||||
app = Flask(__name__, template_folder=_TEMPLATE_FOLDER, static_folder=_STATIC_FOLDER)
|
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()
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ from ..game_dir import game_dir
|
|||||||
from ..slugify import slugify
|
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
|
"""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)."""
|
reliés entre eux, et sa base de données dédiée (nom du jeu en méta).
|
||||||
slug = slugify(name)
|
|
||||||
|
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
|
base = slug
|
||||||
n = 2
|
n = 2
|
||||||
while os.path.isdir(game_dir(base)):
|
while os.path.isdir(game_dir(base)):
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
Flask==3.0.3
|
Flask==3.0.3
|
||||||
|
pyotp==2.10.0
|
||||||
|
qrcode==8.2
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
from . import games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars
|
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import register, register_2fa, login, login_2fa, logout
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from flask import redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
|
import auth
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if session.get("user_id"):
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
error = None
|
||||||
|
email = ""
|
||||||
|
next_url = request.values.get("next") or ""
|
||||||
|
if not (next_url.startswith("/") and not next_url.startswith("//")):
|
||||||
|
next_url = ""
|
||||||
|
if request.method == "POST":
|
||||||
|
email = request.form.get("email", "").strip()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
next_url = request.form.get("next") or next_url
|
||||||
|
user = auth.get_user_by_email(email)
|
||||||
|
if not user or not auth.verify_password(user, password):
|
||||||
|
error = "Adresse email ou mot de passe incorrect."
|
||||||
|
else:
|
||||||
|
# Mot de passe correct, mais pas encore connecté : le second
|
||||||
|
# facteur (TOTP) reste à vérifier — voir login_2fa.py. Session
|
||||||
|
# "en attente" distincte de "user_id" (la vraie connexion),
|
||||||
|
# pour qu'un mot de passe seul ne suffise jamais à se connecter.
|
||||||
|
session["pending_login_user_id"] = user["id"]
|
||||||
|
session["pending_login_next"] = next_url
|
||||||
|
return redirect(url_for("login_2fa"))
|
||||||
|
return render_template("auth/login.html", error=error, email=email, next_url=next_url)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from flask import redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
|
import auth
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/login/2fa", methods=["GET", "POST"])
|
||||||
|
def login_2fa():
|
||||||
|
user_id = session.get("pending_login_user_id")
|
||||||
|
if not user_id:
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
user = auth.get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
session.pop("pending_login_user_id", None)
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
error = None
|
||||||
|
if request.method == "POST":
|
||||||
|
code = request.form.get("code", "")
|
||||||
|
if not auth.verify_totp(user["totp_secret"], code):
|
||||||
|
error = "Code invalide."
|
||||||
|
else:
|
||||||
|
next_url = session.pop("pending_login_next", "") or ""
|
||||||
|
session.pop("pending_login_user_id", None)
|
||||||
|
session["user_id"] = user_id
|
||||||
|
# "/" seul (chemin relatif interne) accepté, jamais une URL
|
||||||
|
# absolue/protocole-relative ("//evil.com") — sans ce garde,
|
||||||
|
# "next" (posé par core/auth_guard.py au moment d'une
|
||||||
|
# redirection vers /login) serait une ouverture classique de
|
||||||
|
# redirection ouverte (open redirect).
|
||||||
|
if next_url.startswith("/") and not next_url.startswith("//"):
|
||||||
|
return redirect(next_url)
|
||||||
|
if user["role"] == "admin":
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
|
||||||
|
return render_template("auth/login_2fa.html", error=error)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from flask import redirect, session, url_for
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/logout", methods=["POST"])
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("login"))
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from flask import redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
|
import auth
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/register", methods=["GET", "POST"])
|
||||||
|
def register():
|
||||||
|
error = None
|
||||||
|
form = {"email": "", "nom": "", "prenom": ""}
|
||||||
|
if request.method == "POST":
|
||||||
|
form["email"] = request.form.get("email", "").strip()
|
||||||
|
form["nom"] = request.form.get("nom", "").strip()
|
||||||
|
form["prenom"] = request.form.get("prenom", "").strip()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
password_confirm = request.form.get("password_confirm", "")
|
||||||
|
if password != password_confirm:
|
||||||
|
error = "Les deux mots de passe ne correspondent pas."
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
user_id = auth.create_user(form["email"], password, form["nom"], form["prenom"])
|
||||||
|
except auth.UserCreationError as exc:
|
||||||
|
error = str(exc)
|
||||||
|
else:
|
||||||
|
# Le compte existe mais n'est pas encore utilisable (2FA
|
||||||
|
# pas encore confirmée, voir create_user.py) — une session
|
||||||
|
# "en attente" DISTINCTE de la vraie connexion (session
|
||||||
|
# "user_id"), pour qu'un compte jamais confirmé ne puisse
|
||||||
|
# jamais se retrouver connecté malgré lui.
|
||||||
|
session["pending_register_user_id"] = user_id
|
||||||
|
return redirect(url_for("register_2fa"))
|
||||||
|
return render_template(
|
||||||
|
"auth/register.html", error=error, form=form,
|
||||||
|
min_score_required=auth.MIN_SCORE_REQUIRED,
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from flask import redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
|
import auth
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/register/2fa", methods=["GET", "POST"])
|
||||||
|
def register_2fa():
|
||||||
|
user_id = session.get("pending_register_user_id")
|
||||||
|
if not user_id:
|
||||||
|
return redirect(url_for("register"))
|
||||||
|
user = auth.get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
session.pop("pending_register_user_id", None)
|
||||||
|
return redirect(url_for("register"))
|
||||||
|
|
||||||
|
error = None
|
||||||
|
if request.method == "POST":
|
||||||
|
code = request.form.get("code", "")
|
||||||
|
if not auth.verify_totp(user["totp_secret"], code):
|
||||||
|
error = "Code invalide — vérifie l'heure de ton téléphone et réessaie."
|
||||||
|
else:
|
||||||
|
auth.confirm_totp(user_id)
|
||||||
|
# Un compte "user" (pas "admin") reçoit ICI son unique projet,
|
||||||
|
# nommé d'après son adresse email (voir create_user.py — le
|
||||||
|
# rôle est déjà tranché à la création : premier compte jamais
|
||||||
|
# créé = admin, tous les suivants = user) — un admin, lui,
|
||||||
|
# reste libre de créer ses jeux comme avant ("+ Nouveau jeu"),
|
||||||
|
# aucun projet ne lui est imposé d'office.
|
||||||
|
if user["role"] != "admin":
|
||||||
|
slug = db.create_game(f"Jeu de {user['prenom']}", slug_override=user["email"])
|
||||||
|
auth.set_project_slug(user_id, slug)
|
||||||
|
user = auth.get_user_by_id(user_id)
|
||||||
|
session.pop("pending_register_user_id", None)
|
||||||
|
session["user_id"] = user_id
|
||||||
|
if user["role"] == "admin":
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
|
||||||
|
|
||||||
|
qr_svg = auth.totp_qrcode_svg(user["totp_secret"], user["email"])
|
||||||
|
return render_template(
|
||||||
|
"auth/register_2fa.html", error=error, qr_svg=qr_svg,
|
||||||
|
secret=user["totp_secret"], email=user["email"],
|
||||||
|
)
|
||||||
@@ -569,3 +569,22 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
|
|||||||
Tableau n'est qu'une liste de valeurs, voir openVarValuePanel() dans
|
Tableau n'est qu'une liste de valeurs, voir openVarValuePanel() dans
|
||||||
game_dashboard.html. */
|
game_dashboard.html. */
|
||||||
#varValuePanel[data-mode="tableau"] .varValueKeyField{ display:none; }
|
#varValuePanel[data-mode="tableau"] .varValueKeyField{ display:none; }
|
||||||
|
|
||||||
|
/* ---------- Inscription : schéma visuel du mot de passe ---------- */
|
||||||
|
/* Jauge + liste de critères mise à jour en direct (voir register.html) —
|
||||||
|
les mêmes 5 critères que auth/password_strength.py côté serveur. */
|
||||||
|
.passwordStrengthBar{ height:6px; border-radius:3px; background:var(--panel2); overflow:hidden; margin-top:8px; }
|
||||||
|
.passwordStrengthFill{ height:100%; width:0; border-radius:3px; background:var(--danger, #e05252); transition:width .15s ease, background-color .15s ease; }
|
||||||
|
.passwordStrengthFill.weak{ background:var(--danger, #e05252); }
|
||||||
|
.passwordStrengthFill.medium{ background:#e0a952; }
|
||||||
|
.passwordStrengthFill.good{ background:#d7d652; }
|
||||||
|
.passwordStrengthFill.strong{ background:#4caf50; }
|
||||||
|
.passwordChecklist{ list-style:none; margin:8px 0 0; padding:0; display:flex; flex-direction:column; gap:2px; }
|
||||||
|
.passwordChecklist li{ font-size:12.5px; color:var(--text-dim); padding-left:18px; position:relative; }
|
||||||
|
.passwordChecklist li::before{ content:"○"; position:absolute; left:0; }
|
||||||
|
.passwordChecklist li.ok{ color:#4caf50; }
|
||||||
|
.passwordChecklist li.ok::before{ content:"✓"; }
|
||||||
|
|
||||||
|
/* ---------- Inscription : QR code de la double authentification ---------- */
|
||||||
|
.totpQrWrap{ background:#fff; padding:16px; border-radius:8px; display:inline-block; max-width:220px; }
|
||||||
|
.totpQrWrap svg{ display:block; width:100%; height:auto; }
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Connexion — Forge Engine{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="container" style="max-width:420px;">
|
||||||
|
<h1 class="title">Connexion</h1>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="notification is-danger">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('login') }}" autocomplete="off">
|
||||||
|
<input type="hidden" name="next" value="{{ next_url }}">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Adresse email</label>
|
||||||
|
<div class="control"><input class="input" type="email" name="email" value="{{ email }}" required autofocus></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Mot de passe</label>
|
||||||
|
<div class="control"><input class="input" type="password" name="password" required autocomplete="current-password"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field mt-4">
|
||||||
|
<button type="submit" class="button primary is-fullwidth">Continuer</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="hint mt-3">Pas encore de compte ? <a href="{{ url_for('register') }}">S'inscrire</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Code de vérification — Forge Engine{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="container" style="max-width:420px;">
|
||||||
|
<h1 class="title">Code de vérification</h1>
|
||||||
|
<p class="hint mb-4">Ouvre ton application d'authentification et saisis le code à 6 chiffres affiché.</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="notification is-danger">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('login_2fa') }}">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Code à 6 chiffres</label>
|
||||||
|
<div class="control"><input class="input" type="text" name="code" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required autofocus></div>
|
||||||
|
</div>
|
||||||
|
<div class="field mt-4">
|
||||||
|
<button type="submit" class="button primary is-fullwidth">Se connecter</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Inscription — Forge Engine{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="container" style="max-width:480px;">
|
||||||
|
<h1 class="title">Créer un compte</h1>
|
||||||
|
<p class="hint mb-4">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.</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="notification is-danger">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('register') }}" autocomplete="off">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Prénom</label>
|
||||||
|
<div class="control"><input class="input" type="text" name="prenom" value="{{ form.prenom }}" required></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Nom</label>
|
||||||
|
<div class="control"><input class="input" type="text" name="nom" value="{{ form.nom }}" required></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Adresse email</label>
|
||||||
|
<div class="control"><input class="input" type="email" name="email" value="{{ form.email }}" required autocomplete="off"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Mot de passe</label>
|
||||||
|
<div class="control"><input class="input" type="password" id="password" name="password" required autocomplete="new-password"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schéma visuel du mot de passe : une jauge + une liste de
|
||||||
|
critères qui passent au vert au fur et à mesure, mis à jour à
|
||||||
|
chaque frappe — jamais une simple règle écrite qu'il faut
|
||||||
|
deviner comment satisfaire. Les MÊMES règles sont vérifiées
|
||||||
|
côté serveur (auth/password_strength.py) : un mot de passe
|
||||||
|
refusé après soumission correspond toujours à une jauge déjà
|
||||||
|
rouge/incomplète ici, jamais une surprise. -->
|
||||||
|
<div class="field">
|
||||||
|
<div class="passwordStrengthBar"><div class="passwordStrengthFill" id="pwFill"></div></div>
|
||||||
|
<ul class="passwordChecklist" id="pwChecklist">
|
||||||
|
<li data-check="longueur">Au moins 8 caractères</li>
|
||||||
|
<li data-check="majuscule">Une majuscule</li>
|
||||||
|
<li data-check="minuscule">Une minuscule</li>
|
||||||
|
<li data-check="chiffre">Un chiffre</li>
|
||||||
|
<li data-check="special">Un caractère spécial (ex. ! ? # -)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Confirmer le mot de passe</label>
|
||||||
|
<div class="control"><input class="input" type="password" name="password_confirm" required autocomplete="new-password"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field mt-4">
|
||||||
|
<button type="submit" class="button primary is-fullwidth">S'inscrire</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="hint mt-3">Déjà un compte ? <a href="{{ url_for('login') }}">Se connecter</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var MIN_SCORE_REQUIRED = {{ min_score_required }};
|
||||||
|
var PW_CHECKS = {
|
||||||
|
longueur: function (p) { return p.length >= 8; },
|
||||||
|
majuscule: function (p) { return /[A-Z]/.test(p); },
|
||||||
|
minuscule: function (p) { return /[a-z]/.test(p); },
|
||||||
|
chiffre: function (p) { return /\d/.test(p); },
|
||||||
|
special: function (p) { return /[^A-Za-z0-9]/.test(p); },
|
||||||
|
};
|
||||||
|
(function () {
|
||||||
|
var input = document.getElementById('password');
|
||||||
|
var fill = document.getElementById('pwFill');
|
||||||
|
var items = document.querySelectorAll('#pwChecklist li');
|
||||||
|
if (!input) return;
|
||||||
|
function update() {
|
||||||
|
var value = input.value || '';
|
||||||
|
var score = 0;
|
||||||
|
items.forEach(function (li) {
|
||||||
|
var ok = PW_CHECKS[li.dataset.check](value);
|
||||||
|
li.classList.toggle('ok', ok);
|
||||||
|
if (ok) score++;
|
||||||
|
});
|
||||||
|
var pct = (score / items.length) * 100;
|
||||||
|
fill.style.width = pct + '%';
|
||||||
|
fill.className = 'passwordStrengthFill' +
|
||||||
|
(score >= items.length ? ' strong' : score >= MIN_SCORE_REQUIRED ? ' good' : score >= 2 ? ' medium' : ' weak');
|
||||||
|
}
|
||||||
|
input.addEventListener('input', update);
|
||||||
|
update();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Activer la double authentification — Forge Engine{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="container" style="max-width:480px;">
|
||||||
|
<h1 class="title">Active la double authentification</h1>
|
||||||
|
<p class="hint mb-4">Scanne ce QR code avec une application d'authentification (Google Authenticator, Authy...), puis tape le code à 6 chiffres qu'elle affiche pour confirmer.</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="notification is-danger">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="totpQrWrap">{{ qr_svg|safe }}</div>
|
||||||
|
<p class="hint">Impossible de scanner ? Saisis cette clé manuellement dans l'application : <code>{{ secret }}</code></p>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('register_2fa') }}" class="mt-4">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Code à 6 chiffres</label>
|
||||||
|
<div class="control"><input class="input" type="text" name="code" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required autofocus></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<button type="submit" class="button primary is-fullwidth">Confirmer et activer mon compte</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -25,6 +25,12 @@
|
|||||||
<div class="navbar-brand">
|
<div class="navbar-brand">
|
||||||
<a href="{{ url_for('index') }}" class="navbar-item brand"><strong>🛠️ Forge Engine</strong></a>
|
<a href="{{ url_for('index') }}" class="navbar-item brand"><strong>🛠️ Forge Engine</strong></a>
|
||||||
</div>
|
</div>
|
||||||
|
{% if g.get('current_user') %}
|
||||||
|
<div class="navbar-end" style="margin-left:auto; display:flex; align-items:center; gap:10px; padding-right:12px;">
|
||||||
|
<span class="hint">{{ g.current_user.prenom }} {{ g.current_user.nom }}{{ ' (admin)' if g.current_user.role == 'admin' else '' }}</span>
|
||||||
|
<form method="post" action="{{ url_for('logout') }}"><button type="submit" class="button is-small">Déconnexion</button></form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
{% if game is defined and game %}
|
{% if game is defined and game %}
|
||||||
<!-- Barre toujours visible entre les différentes pages d'UN MÊME jeu.
|
<!-- Barre toujours visible entre les différentes pages d'UN MÊME jeu.
|
||||||
|
|||||||
@@ -10,16 +10,39 @@ pour ne jamais laisser de données de test dans projects/.
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# Base de comptes (auth/) et clé de session ISOLÉES du poste réel — posé
|
||||||
|
# AVANT tout import de core.flask_app/auth (chemin résolu à chaque appel,
|
||||||
|
# voir auth/connection.py, mais réglé ici une bonne fois pour toute la
|
||||||
|
# session de tests, jamais data/users.db). Sans ça, faire tourner la
|
||||||
|
# suite de tests écrirait de vrais comptes dans la vraie base de comptes,
|
||||||
|
# et la logique "premier compte = admin" (auth/create_user.py) serait déjà
|
||||||
|
# consommée par un run précédent.
|
||||||
|
_TEST_AUTH_DIR = tempfile.mkdtemp(prefix="forge_test_auth_")
|
||||||
|
os.environ["FORGE_USERS_DB_PATH"] = os.path.join(_TEST_AUTH_DIR, "users.db")
|
||||||
|
os.environ["FORGE_SECRET_KEY_PATH"] = os.path.join(_TEST_AUTH_DIR, "secret_key")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.flask_app import app as flask_app
|
from core.flask_app import app as flask_app
|
||||||
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja (elabel, elstyle, colname)
|
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja (elabel, elstyle, colname)
|
||||||
import routes # noqa: F401 - enregistre toutes les routes sur flask_app
|
import routes # noqa: F401 - enregistre toutes les routes sur flask_app
|
||||||
|
from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes)
|
||||||
|
import auth
|
||||||
import db
|
import db
|
||||||
|
|
||||||
|
# Un seul compte admin pour toute la session de tests : les ~80 routes
|
||||||
|
# existantes s'attendent (comportement d'avant l'authentification) à un
|
||||||
|
# accès sans restriction — "admin" est le seul rôle illimité (voir
|
||||||
|
# core/auth_guard.py). Créé directement en Python (pas via /register) :
|
||||||
|
# ces tests exercent le RESTE du moteur, pas le parcours d'inscription
|
||||||
|
# lui-même (qui a ses propres tests dédiés, voir test_auth.py).
|
||||||
|
_TEST_ADMIN_ID = auth.create_user("admin@pytest.local", "Test1234!", "Test", "Admin")
|
||||||
|
auth.confirm_totp(_TEST_ADMIN_ID)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def tmp_game_slug_cleanup():
|
def tmp_game_slug_cleanup():
|
||||||
@@ -43,6 +66,8 @@ def tmp_game_slug_cleanup():
|
|||||||
def client():
|
def client():
|
||||||
flask_app.config["TESTING"] = True
|
flask_app.config["TESTING"] = True
|
||||||
with flask_app.test_client() as c:
|
with flask_app.test_client() as c:
|
||||||
|
with c.session_transaction() as sess:
|
||||||
|
sess["user_id"] = _TEST_ADMIN_ID
|
||||||
yield c
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Comptes utilisateurs (auth/) : inscription (nom/prénom/email unique,
|
||||||
|
mot de passe fort, 2FA TOTP obligatoire), connexion, et isolation par
|
||||||
|
utilisateur d'un SEUL projet (sauf le rôle "admin", illimité — voir
|
||||||
|
core/auth_guard.py). Le tout premier compte jamais créé devient
|
||||||
|
automatiquement admin (auth/create_user.py) : la fixture `client` de
|
||||||
|
conftest.py en a déjà créé un pour authentifier tous les AUTRES tests du
|
||||||
|
moteur — ces tests-ci utilisent volontairement un client Flask NU (pas la
|
||||||
|
fixture `client`, déjà connectée) pour exercer le parcours anonyme
|
||||||
|
(inscription/connexion) de bout en bout."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import auth
|
||||||
|
import db
|
||||||
|
from core.flask_app import app as flask_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anon_client():
|
||||||
|
flask_app.config["TESTING"] = True
|
||||||
|
with flask_app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _register(client, email, prenom="Alice", nom="Test", password="Sup3r$ecret!"):
|
||||||
|
return client.post("/register", data={
|
||||||
|
"prenom": prenom, "nom": nom, "email": email,
|
||||||
|
"password": password, "password_confirm": password,
|
||||||
|
}, follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm_2fa(client):
|
||||||
|
"""Récupère la clé TOTP affichée sur la page de confirmation et
|
||||||
|
soumet le code du moment — comme un humain qui vient de scanner le
|
||||||
|
QR code avec son application."""
|
||||||
|
html = client.get("/register/2fa").get_data(as_text=True)
|
||||||
|
secret = re.search(r"<code>([A-Z0-9]+)</code>", html).group(1)
|
||||||
|
code = pyotp.TOTP(secret).now()
|
||||||
|
return client.post("/register/2fa", data={"code": code}, follow_redirects=False), secret
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_project(email):
|
||||||
|
game_dir = db.game_dir(db.slugify(email))
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
if os.path.isdir(game_dir):
|
||||||
|
shutil.rmtree(game_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_request_redirects_to_login(anon_client):
|
||||||
|
resp = anon_client.get("/", follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert resp.headers["Location"].startswith("/login")
|
||||||
|
|
||||||
|
|
||||||
|
def test_registration_requires_matching_password_confirmation(anon_client):
|
||||||
|
resp = anon_client.post("/register", data={
|
||||||
|
"prenom": "A", "nom": "B", "email": "mismatch@example.com",
|
||||||
|
"password": "Sup3r$ecret!", "password_confirm": "Autre$ecret1",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "ne correspondent pas" in resp.get_data(as_text=True)
|
||||||
|
assert auth.get_user_by_email("mismatch@example.com") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_registration_rejects_a_weak_password(anon_client):
|
||||||
|
resp = anon_client.post("/register", data={
|
||||||
|
"prenom": "A", "nom": "B", "email": "weak@example.com",
|
||||||
|
"password": "abcdefgh", "password_confirm": "abcdefgh",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "faible" in resp.get_data(as_text=True)
|
||||||
|
assert auth.get_user_by_email("weak@example.com") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_registration_rejects_a_duplicate_email(anon_client):
|
||||||
|
_register(anon_client, "dup@example.com")
|
||||||
|
resp = _register(anon_client, "dup@example.com")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "existe déjà" in resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_registration_flow_requires_totp_before_account_is_usable(anon_client):
|
||||||
|
resp = _register(anon_client, "flow@example.com")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert resp.headers["Location"] == "/register/2fa"
|
||||||
|
|
||||||
|
user = auth.get_user_by_email("flow@example.com")
|
||||||
|
assert user["totp_confirmed"] == 0
|
||||||
|
# Compte pas encore confirmé -> pas encore une vraie session connectée,
|
||||||
|
# même après l'étape de mot de passe (voir register.py : la connexion
|
||||||
|
# ("user_id") n'est posée qu'après confirm_totp(), jamais avant).
|
||||||
|
resp = anon_client.get("/", follow_redirects=False)
|
||||||
|
assert resp.status_code == 302 # toujours redirigé vers /login
|
||||||
|
|
||||||
|
resp, secret = _confirm_2fa(anon_client)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
user = auth.get_user_by_email("flow@example.com")
|
||||||
|
assert user["totp_confirmed"] == 1
|
||||||
|
assert user["role"] == "user" # jamais le premier compte de la session de tests
|
||||||
|
assert user["project_slug"] == db.slugify("flow@example.com")
|
||||||
|
try:
|
||||||
|
assert resp.headers["Location"] == "/game/" + user["project_slug"]
|
||||||
|
# Connecté pour de vrai maintenant.
|
||||||
|
resp2 = anon_client.get("/", follow_redirects=False)
|
||||||
|
assert resp2.status_code == 302
|
||||||
|
assert resp2.headers["Location"] == "/game/" + user["project_slug"]
|
||||||
|
finally:
|
||||||
|
_cleanup_project("flow@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_totp_code_is_rejected(anon_client):
|
||||||
|
_register(anon_client, "badcode@example.com")
|
||||||
|
resp = anon_client.post("/register/2fa", data={"code": "000000"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "invalide" in resp.get_data(as_text=True)
|
||||||
|
user = auth.get_user_by_email("badcode@example.com")
|
||||||
|
assert user["totp_confirmed"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_requires_correct_password_then_totp(anon_client):
|
||||||
|
_register(anon_client, "login@example.com")
|
||||||
|
resp, secret = _confirm_2fa(anon_client)
|
||||||
|
slug = auth.get_user_by_email("login@example.com")["project_slug"]
|
||||||
|
try:
|
||||||
|
anon_client.post("/logout")
|
||||||
|
|
||||||
|
resp = anon_client.post("/login", data={"email": "login@example.com", "password": "wrong"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "incorrect" in resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
resp = anon_client.post("/login", data={"email": "login@example.com", "password": "Sup3r$ecret!"}, follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert resp.headers["Location"] == "/login/2fa"
|
||||||
|
|
||||||
|
code = pyotp.TOTP(secret).now()
|
||||||
|
resp = anon_client.post("/login/2fa", data={"code": code}, follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert resp.headers["Location"] == "/game/" + slug
|
||||||
|
finally:
|
||||||
|
_cleanup_project("login@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_user_is_isolated_to_their_own_project(anon_client):
|
||||||
|
_register(anon_client, "isolated@example.com")
|
||||||
|
_confirm_2fa(anon_client)
|
||||||
|
user = auth.get_user_by_email("isolated@example.com")
|
||||||
|
try:
|
||||||
|
# Un autre projet (celui de l'admin partagé par conftest.py, ou
|
||||||
|
# n'importe quel autre slug) doit rester inaccessible.
|
||||||
|
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
resp = anon_client.get(f"/game/{user['project_slug']}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Impossible de créer un second projet : redirigé vers le sien.
|
||||||
|
resp = anon_client.post("/games/new", data={"name": "second"}, follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert resp.headers["Location"] == "/game/" + user["project_slug"]
|
||||||
|
finally:
|
||||||
|
_cleanup_project("isolated@example.com")
|
||||||
|
# Filet de sécurité si "second" avait quand même été créé (ne
|
||||||
|
# devrait jamais arriver, voir l'assertion ci-dessus).
|
||||||
|
_cleanup_project("second")
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_user_cannot_delete_their_only_project(anon_client):
|
||||||
|
"""Sans issue de secours (games_new renvoie toujours vers son
|
||||||
|
project_slug, existant ou non), le supprimer serait un piège sans
|
||||||
|
retour — bloqué plutôt que risqué."""
|
||||||
|
_register(anon_client, "nodelete@example.com")
|
||||||
|
_confirm_2fa(anon_client)
|
||||||
|
user = auth.get_user_by_email("nodelete@example.com")
|
||||||
|
try:
|
||||||
|
resp = anon_client.post(f"/game/{user['project_slug']}/delete")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert db.game_meta(user["project_slug"]) is not None
|
||||||
|
finally:
|
||||||
|
_cleanup_project("nodelete@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_role_is_not_restricted_to_a_single_project(client, game):
|
||||||
|
"""`client`/`game` (conftest.py) sont déjà connectés en tant qu'admin
|
||||||
|
de la session de tests — la création d'un second jeu, déjà exercée par
|
||||||
|
des dizaines d'autres tests via la fixture `game`, doit continuer de
|
||||||
|
fonctionner sans redirection forcée."""
|
||||||
|
resp = client.post("/games/new", data={"name": "pytest_second_admin_game"}, follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
|
||||||
|
assert slug != game
|
||||||
|
import shutil
|
||||||
|
import os
|
||||||
|
game_dir = db.game_dir(slug)
|
||||||
|
if os.path.isdir(game_dir):
|
||||||
|
shutil.rmtree(game_dir)
|
||||||
Reference in New Issue
Block a user