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:
co-authored by
Claude Sonnet 5
parent
3032b27740
commit
989e899a96
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user