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>
This commit is contained in:
william
2026-08-28 20:09:35 +02:00
co-authored by Claude Sonnet 5
parent 3032b27740
commit 989e899a96
9 changed files with 221 additions and 8 deletions
+81
View File
@@ -182,6 +182,87 @@ def test_non_admin_user_cannot_delete_their_only_project(anon_client):
_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