10 codes à usage unique (format "xxxx-xxxx-xxxx") sont générés à
l'instant même où la 2FA est confirmée (auth/recovery_codes.py, table
_recovery_codes séparée pour marquer/consommer chaque code un par un) —
seul leur hash (werkzeug, comme les mots de passe) est stocké, ils ne
sont visibles en clair qu'à cet instant précis.
Plutôt que d'interrompre la redirection habituelle après confirmation de
la 2FA, les codes sont posés en session ("recovery_codes_to_show") et
affichés une seule fois, en modal, dès le premier rendu de base.html qui
suit (core/recovery_codes_flash.py, session.pop) — préserve tel quel le
comportement de redirection déjà couvert par les tests existants.
Sur /login/2fa, un code de récupération est accepté à la place du code
TOTP habituel (routes/auth/login_2fa.py) : verify_totp est essayé en
premier (verify_recovery_code consomme le code dès qu'il correspond, on
ne veut pas en griller un pour rien sur une saisie qui aurait en fait
été un TOTP valide). Compte toujours vers le même compteur anti-bruteforce
que le code TOTP (déjà en place, voir auth/rate_limit.py).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
324 lines
14 KiB
Python
324 lines
14 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_recovery_codes_are_shown_once_after_confirming_2fa(anon_client):
|
|
_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):
|
|
_register(anon_client, "recoverylogin@example.com")
|
|
_confirm_2fa(anon_client)
|
|
user = 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, 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)
|