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
@@ -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