Dev #4
@@ -17,6 +17,10 @@ Bug/
|
||||
# Jeux créés par les utilisateurs (données runtime, pas du code)
|
||||
projects/
|
||||
|
||||
# Comptes utilisateurs (base SQLite + clé de session) — données runtime,
|
||||
# jamais du code, et sensibles (mots de passe hachés, secrets 2FA).
|
||||
data/
|
||||
|
||||
# OS / éditeurs
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -21,6 +21,7 @@ import webbrowser
|
||||
from core.flask_app import app
|
||||
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja
|
||||
import routes # noqa: F401 - enregistre toutes les routes sur `app`
|
||||
from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes)
|
||||
|
||||
|
||||
def _open_browser():
|
||||
|
||||
@@ -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 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()
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -1 +1,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
|
||||
game_dashboard.html. */
|
||||
#varValuePanel[data-mode="tableau"] .varValueKeyField{ display:none; }
|
||||
|
||||
/* ---------- Inscription : schéma visuel du mot de passe ---------- */
|
||||
/* Jauge + liste de critères mise à jour en direct (voir register.html) —
|
||||
les mêmes 5 critères que auth/password_strength.py côté serveur. */
|
||||
.passwordStrengthBar{ height:6px; border-radius:3px; background:var(--panel2); overflow:hidden; margin-top:8px; }
|
||||
.passwordStrengthFill{ height:100%; width:0; border-radius:3px; background:var(--danger, #e05252); transition:width .15s ease, background-color .15s ease; }
|
||||
.passwordStrengthFill.weak{ background:var(--danger, #e05252); }
|
||||
.passwordStrengthFill.medium{ background:#e0a952; }
|
||||
.passwordStrengthFill.good{ background:#d7d652; }
|
||||
.passwordStrengthFill.strong{ background:#4caf50; }
|
||||
.passwordChecklist{ list-style:none; margin:8px 0 0; padding:0; display:flex; flex-direction:column; gap:2px; }
|
||||
.passwordChecklist li{ font-size:12.5px; color:var(--text-dim); padding-left:18px; position:relative; }
|
||||
.passwordChecklist li::before{ content:"○"; position:absolute; left:0; }
|
||||
.passwordChecklist li.ok{ color:#4caf50; }
|
||||
.passwordChecklist li.ok::before{ content:"✓"; }
|
||||
|
||||
/* ---------- Inscription : QR code de la double authentification ---------- */
|
||||
.totpQrWrap{ background:#fff; padding:16px; border-radius:8px; display:inline-block; max-width:220px; }
|
||||
.totpQrWrap svg{ display:block; width:100%; height:auto; }
|
||||
|
||||
@@ -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">
|
||||
<a href="{{ url_for('index') }}" class="navbar-item brand"><strong>🛠️ Forge Engine</strong></a>
|
||||
</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>
|
||||
{% if game is defined and game %}
|
||||
<!-- 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 shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
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
|
||||
|
||||
from core.flask_app import app as flask_app
|
||||
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
|
||||
from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes)
|
||||
import auth
|
||||
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
|
||||
def tmp_game_slug_cleanup():
|
||||
@@ -43,6 +66,8 @@ def tmp_game_slug_cleanup():
|
||||
def client():
|
||||
flask_app.config["TESTING"] = True
|
||||
with flask_app.test_client() as c:
|
||||
with c.session_transaction() as sess:
|
||||
sess["user_id"] = _TEST_ADMIN_ID
|
||||
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