Files
Forge-Engine/tests/test_auth.py
T
williamandClaude Sonnet 5 989e899a96 Ajoute un anti-bruteforce (3 essais libres puis 5/10/20/40 min, plafonné à 1h)
Sur la connexion (mot de passe + code 2FA, même compteur pour les deux —
un attaquant qui connaît le mot de passe ne doit pas avoir un nombre
illimité d'essais sur le code) et sur la confirmation 2FA de
l'inscription : 3 tentatives libres, puis un verrouillage qui double à
chaque nouvel échec (5, 10, 20, 40 minutes...), plafonné à 1h
(auth/rate_limit.py). Remis à zéro dès une connexion RÉELLEMENT aboutie
(mot de passe ET code corrects) — jamais sur le seul succès du mot de
passe, pour ne jamais donner un nombre illimité d'essais sur le 2FA à qui
connaît déjà le mot de passe.

Le verrouillage est annoncé IMMÉDIATEMENT sur la réponse qui le déclenche
(record_failed_attempt renvoie la durée qu'il vient de poser), pas
seulement découvert au prochain essai. Colonnes ajoutées en ALTER TABLE
(failed_attempts, locked_until) pour ne rien casser sur une base de
comptes déjà créée avant cette fonctionnalité.

14 tests dans test_auth.py (dont l'escalade 5/10/20/40/60, le blocage
même avec le bon mot de passe une fois verrouillé, et la remise à zéro
sur connexion réussie). 169 tests au total, tous au vert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:09:35 +02:00

280 lines
12 KiB
Python

"""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_lockout_escalation_is_5_10_20_40_capped_at_60():
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):
"""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)
user = auth.get_user_by_email("bruteforce@example.com")
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 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 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):
_register(anon_client, "bruteforce2fa@example.com")
resp, secret = _confirm_2fa(anon_client)
user = auth.get_user_by_email("bruteforce2fa@example.com")
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):
_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 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 = 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_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)