Files
Forge-Engine/tests/test_auth.py
T
williamandClaude Sonnet 5 c57420c8c9 Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y
Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:06:15 +02:00

438 lines
20 KiB
Python

"""Comptes utilisateurs (auth/) : inscription (nom/prénom/email unique,
mot de passe fort, 2FA TOTP obligatoire), connexion, et isolation par
PROPRIÉTAIRE de projet — pour TOUS les rôles, y compris "admin" (qui
garde seulement le droit d'avoir plusieurs projets À LUI, 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
from collections.abc import Generator
from typing import Any
import pyotp
import pytest
from flask.testing import FlaskClient
import auth
import db
from core.flask_app import app as flask_app
from tests.conftest import not_none
@pytest.fixture
def anon_client() -> Generator[FlaskClient, None, None]:
flask_app.config["TESTING"] = True
with flask_app.test_client() as c:
yield c
def _register(
client: FlaskClient,
email: str,
prenom: str = "Alice",
nom: str = "Test",
password: str = "Sup3r$ecret!", # nosec B107 # noqa: S107 - mot de passe de fixture de test, jamais un vrai compte
) -> Any:
return client.post(
"/register",
data={
"prenom": prenom,
"nom": nom,
"email": email,
"password": password,
"password_confirm": password,
},
follow_redirects=False,
)
def _confirm_2fa(client: FlaskClient) -> Any:
"""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. Un compte "user" n'a plus de projet
créé d'office ici (voir routes/onboarding/onboarding_new.py) — la
redirection renvoie désormais vers /onboarding, pas /game/<slug> ;
voir _complete_onboarding ci-dessous pour les tests qui ont besoin
d'un vrai projet ensuite."""
html = client.get("/register/2fa").get_data(as_text=True)
secret = not_none(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 _complete_onboarding(client: FlaskClient, name: str = "Mon jeu", onboarding_type: str = "rpg") -> Any:
"""Termine l'onboarding guidé (voir routes/onboarding/onboarding_new.py)
— la plupart des tests de ce fichier n'ont pas besoin de tester
l'onboarding lui-même (voir test_onboarding.py), juste d'obtenir un
project_slug réel pour la suite ; "rpg" est le seul type restant."""
return client.post("/onboarding", data={"onboarding_type": onboarding_type, "name": name}, follow_redirects=False)
def _cleanup_project(email: str) -> None:
import os
user = auth.get_user_by_email(email)
if user and user.get("project_slug") and os.path.isdir(db.game_dir(user["project_slug"])):
# db.delete_game (voir db/games/project_slug.py — structure de
# dossiers par utilisateur) nettoie aussi le dossier
# propriétaire s'il devient vide, contrairement à un simple
# shutil.rmtree(game_dir) — sans ça, des dossiers propriétaires
# vides s'accumulent sous projects/ d'un run de tests à l'autre
# (déjà observé : ça a fini par provoquer une collision de nom
# dans db.move_game, un dossier vide comptant comme "occupé").
db.delete_game(user["project_slug"])
def test_anonymous_request_redirects_to_login(anon_client: FlaskClient) -> None:
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: FlaskClient) -> None:
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: FlaskClient) -> None:
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: FlaskClient) -> None:
_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: FlaskClient) -> None:
resp = _register(anon_client, "flow@example.com")
assert resp.status_code == 302
assert resp.headers["Location"] == "/register/2fa"
user = not_none(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 = not_none(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
# Onboarding guidé (voir routes/onboarding/onboarding_new.py) : plus
# de projet créé d'office ici — connecté mais sans project_slug tant
# qu'il n'a pas choisi un type de jeu.
assert user["project_slug"] is None
try:
assert resp.headers["Location"] == "/onboarding"
# Connecté pour de vrai maintenant, mais renvoyé vers l'onboarding
# tant qu'aucun projet n'existe (voir core/auth_guard.py).
resp2 = anon_client.get("/", follow_redirects=False)
assert resp2.status_code == 302
assert resp2.headers["Location"] == "/onboarding"
onboard_resp = _complete_onboarding(anon_client)
assert onboard_resp.status_code == 302
user = not_none(auth.get_user_by_email("flow@example.com"))
assert user["project_slug"] is not None
assert onboard_resp.headers["Location"] == "/game/" + user["project_slug"]
finally:
_cleanup_project("flow@example.com")
def test_wrong_totp_code_is_rejected(anon_client: FlaskClient) -> None:
_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 = not_none(auth.get_user_by_email("badcode@example.com"))
assert user["totp_confirmed"] == 0
def test_login_requires_correct_password_then_totp(anon_client: FlaskClient) -> None:
_register(anon_client, "login@example.com")
resp, secret = _confirm_2fa(anon_client)
_complete_onboarding(anon_client)
slug = not_none(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: FlaskClient) -> None:
_register(anon_client, "isolated@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = not_none(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 _create_isolated_user_with_project(email: str, name: str = "Jeu de la victime") -> Any:
"""Compte + projet créés directement (sans passer par le client HTTP
de test) — combiner ici deux instances de `test_client()` actives en
parallèle (la fixture `client`, admin partagé, ET une seconde pour
créer la victime) fait planter Werkzeug ("Popped wrong app context"),
une limite connue du client de test Flask quand deux contextes de
requête s'entremêlent plutôt que de s'imbriquer proprement."""
user_id = auth.create_user(email, "Sup3r$ecret!", "Test", "Victime")
auth.confirm_totp(user_id)
slug = db.create_game(name, owner_folder=str(user_id))
auth.set_project_slug(user_id, slug)
return auth.get_user_by_id(user_id)
def test_admin_cannot_access_another_users_project(client: FlaskClient) -> None:
"""Faille corrigée : le rôle "admin" contournait entièrement
l'isolation par projet (voir core/auth_guard.py) — un admin pouvait
ouvrir/modifier/supprimer le jeu de N'IMPORTE QUEL autre compte en
connaissant simplement son slug. `client` (conftest.py) est déjà
connecté en admin ; désormais bloqué exactement comme un "user"."""
victim = _create_isolated_user_with_project("adminvictim@example.com")
try:
assert client.get(f"/game/{victim['project_slug']}").status_code == 403
assert client.get(f"/game/{victim['project_slug']}/screens/1/edit").status_code == 403
assert client.post(f"/game/{victim['project_slug']}/delete").status_code == 403
# Le projet de la victime doit être resté intact (la tentative de
# suppression ci-dessus doit avoir été bloquée AVANT toute écriture).
assert db.game_meta(victim["project_slug"]) is not None
finally:
_cleanup_project("adminvictim@example.com")
def test_admin_home_page_only_lists_their_own_projects(client: FlaskClient, game: str) -> None:
"""routes/games/index.py (page "Mes jeux") scannait auparavant TOUT
projects/ sans filtrage (db.list_games()) — n'importe quel admin
voyait donc le slug (et le nom de dossier propriétaire) de chaque
compte. Corrigé : ne renvoie plus que les projets du compte connecté,
admin compris."""
victim = _create_isolated_user_with_project("adminlistvictim@example.com")
try:
resp = client.get("/")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
assert game in html
assert victim["project_slug"] not in html
finally:
_cleanup_project("adminlistvictim@example.com")
def test_non_admin_user_cannot_delete_their_only_project(anon_client: FlaskClient) -> None:
"""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)
_complete_onboarding(anon_client)
user = not_none(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_lockout_escalation_is_5_10_20_40_capped_at_60() -> None:
assert [auth.lockout_minutes_for(n) for n in range(9)] == [0, 0, 0, 0, 5, 10, 20, 40, 60]
def test_login_locks_out_after_3_free_attempts_then_escalates(anon_client: FlaskClient) -> None:
"""3 tentatives libres, puis un verrouillage qui double à chaque
nouvel échec (5, 10, 20 min...) — voir auth/rate_limit.py."""
_register(anon_client, "bruteforce@example.com")
_confirm_2fa(anon_client)
try:
anon_client.post("/logout")
for _ in range(3):
resp = anon_client.post("/login", data={"email": "bruteforce@example.com", "password": "wrong"})
assert "incorrect" in resp.get_data(as_text=True)
assert not_none(auth.get_user_by_email("bruteforce@example.com"))["failed_attempts"] == 3
assert auth.seconds_locked_remaining(auth.get_user_by_email("bruteforce@example.com")) == 0
# 4e échec -> premier verrouillage (5 minutes).
resp = anon_client.post("/login", data={"email": "bruteforce@example.com", "password": "wrong"})
body = resp.get_data(as_text=True)
assert "Trop de tentatives" in body
assert "5 minute" in body
locked_user = auth.get_user_by_email("bruteforce@example.com")
assert auth.seconds_locked_remaining(locked_user) > 0
# Même avec le BON mot de passe, verrouillé = verrouillé.
resp = anon_client.post("/login", data={"email": "bruteforce@example.com", "password": "Sup3r$ecret!"})
assert "Trop de tentatives" in resp.get_data(as_text=True)
# Un essai bloqué par le verrouillage n'incrémente pas davantage
# le compteur (pas d'escalade supplémentaire tant qu'on est
# encore dans la fenêtre déjà posée).
assert not_none(auth.get_user_by_email("bruteforce@example.com"))["failed_attempts"] == 4
finally:
_cleanup_project("bruteforce@example.com")
def test_login_2fa_wrong_code_also_counts_towards_the_same_lockout(anon_client: FlaskClient) -> None:
_register(anon_client, "bruteforce2fa@example.com")
resp, secret = _confirm_2fa(anon_client)
try:
anon_client.post("/logout")
anon_client.post("/login", data={"email": "bruteforce2fa@example.com", "password": "Sup3r$ecret!"})
for _ in range(3):
resp = anon_client.post("/login/2fa", data={"code": "000000"})
assert "Code invalide" in resp.get_data(as_text=True)
resp = anon_client.post("/login/2fa", data={"code": "000000"})
assert "Trop de tentatives" in resp.get_data(as_text=True)
# Le bon code ne débloque rien tant que le verrouillage est actif.
code = pyotp.TOTP(secret).now()
resp = anon_client.post("/login/2fa", data={"code": code})
assert "Trop de tentatives" in resp.get_data(as_text=True)
finally:
_cleanup_project("bruteforce2fa@example.com")
def test_successful_login_resets_the_failed_attempts_counter(anon_client: FlaskClient) -> None:
_register(anon_client, "resetcounter@example.com")
resp, secret = _confirm_2fa(anon_client)
try:
anon_client.post("/logout")
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "wrong"})
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "wrong"})
assert not_none(auth.get_user_by_email("resetcounter@example.com"))["failed_attempts"] == 2
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "Sup3r$ecret!"})
code = pyotp.TOTP(secret).now()
resp = anon_client.post("/login/2fa", data={"code": code}, follow_redirects=False)
assert resp.status_code == 302
user = not_none(auth.get_user_by_email("resetcounter@example.com"))
assert user["failed_attempts"] == 0
assert user["locked_until"] is None
finally:
_cleanup_project("resetcounter@example.com")
def test_recovery_codes_are_shown_once_after_confirming_2fa(anon_client: FlaskClient) -> None:
_register(anon_client, "recoveryshown@example.com")
resp, secret = _confirm_2fa(anon_client)
try:
html = anon_client.get(resp.headers["Location"]).get_data(as_text=True)
assert "recoveryCodesModal" in html
# Jamais réaffichés sur un chargement suivant (jeton flash à usage
# unique, voir core/recovery_codes_flash.py).
html_again = anon_client.get(resp.headers["Location"]).get_data(as_text=True)
assert "recoveryCodesModal" not in html_again
finally:
_cleanup_project("recoveryshown@example.com")
def test_login_with_a_recovery_code_instead_of_totp(anon_client: FlaskClient) -> None:
_register(anon_client, "recoverylogin@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = not_none(auth.get_user_by_email("recoverylogin@example.com"))
slug = user["project_slug"]
codes = auth.generate_recovery_codes(user["id"])
try:
anon_client.post("/logout")
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
resp = anon_client.post("/login/2fa", data={"code": codes[0]}, follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == "/game/" + slug
# Un code déjà utilisé ne fonctionne plus une seconde fois.
anon_client.post("/logout")
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
resp = anon_client.post("/login/2fa", data={"code": codes[0]})
assert "Code invalide" in resp.get_data(as_text=True)
# Un autre code du même lot, lui, fonctionne toujours.
anon_client.post("/logout")
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
resp = anon_client.post("/login/2fa", data={"code": codes[1]}, follow_redirects=False)
assert resp.status_code == 302
finally:
_cleanup_project("recoverylogin@example.com")
def test_admin_role_is_not_restricted_to_a_single_project(client: FlaskClient, game: str) -> None:
"""`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 os
import shutil
game_dir = db.game_dir(slug)
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)