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:
william
2026-08-28 19:30:43 +02:00
co-authored by Claude Sonnet 5
parent ed4dd78dad
commit d90abc827b
34 changed files with 942 additions and 4 deletions
+27
View File
@@ -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",
]
+8
View File
@@ -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()
+28
View File
@@ -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
+53
View File
@@ -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
+23
View File
@@ -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()
+10
View File
@@ -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
+12
View File
@@ -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
+14
View File
@@ -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
+29
View File
@@ -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}
+12
View File
@@ -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()
+21
View File
@@ -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")
+5
View File
@@ -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 "")
+12
View File
@@ -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)