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
+5
View File
@@ -16,6 +16,9 @@ from .verify_totp import verify_totp
from .set_project_slug import set_project_slug from .set_project_slug import set_project_slug
from .password_strength import password_strength, MIN_SCORE_REQUIRED from .password_strength import password_strength, MIN_SCORE_REQUIRED
from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg
from .rate_limit import lockout_minutes_for, seconds_locked_remaining, lockout_message
from .record_failed_attempt import record_failed_attempt
from .reset_failed_attempts import reset_failed_attempts
__all__ = [ __all__ = [
"users_db_path", "ensure_users_schema", "is_first_user", "users_db_path", "ensure_users_schema", "is_first_user",
@@ -24,4 +27,6 @@ __all__ = [
"verify_password", "confirm_totp", "verify_totp", "set_project_slug", "verify_password", "confirm_totp", "verify_totp", "set_project_slug",
"password_strength", "MIN_SCORE_REQUIRED", "password_strength", "MIN_SCORE_REQUIRED",
"totp_provisioning_uri", "totp_qrcode_svg", "totp_provisioning_uri", "totp_qrcode_svg",
"lockout_minutes_for", "seconds_locked_remaining", "lockout_message",
"record_failed_attempt", "reset_failed_attempts",
] ]
+10
View File
@@ -19,5 +19,15 @@ def ensure_users_schema():
); );
""" """
) )
# Anti-bruteforce (voir rate_limit.py) : ajoutées en ALTER TABLE pour
# ne rien casser sur une base de comptes déjà créée avant cette
# fonctionnalité (le compte admin déjà inscrit sur ce poste, par
# exemple) — CREATE TABLE IF NOT EXISTS ci-dessus n'aurait sinon
# aucun effet sur une table qui existe déjà sans ces colonnes.
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_users)").fetchall()}
if "failed_attempts" not in cols:
conn.execute("ALTER TABLE _users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0")
if "locked_until" not in cols:
conn.execute("ALTER TABLE _users ADD COLUMN locked_until TEXT")
conn.commit() conn.commit()
conn.close() conn.close()
+50
View File
@@ -0,0 +1,50 @@
"""Anti-bruteforce sur les tentatives de connexion (mot de passe ET code
2FA — même compteur pour les deux, un compte ciblé reste protégé quelle
que soit l'étape attaquée) : 3 essais libres, puis un temps d'attente qui
double à chaque échec supplémentaire (5 min, 10, 20, 40...), plafonné à
1h. Remis à zéro dès une connexion réussie (voir reset_failed_attempts)."""
import math
from datetime import datetime, timedelta, timezone
_FREE_ATTEMPTS = 3
_FIRST_LOCKOUT_MINUTES = 5
_MAX_LOCKOUT_MINUTES = 60
def lockout_minutes_for(failed_attempts):
"""0 tant qu'on est dans les 3 essais libres ; sinon 5 * 2^n, plafonné
à 60 — jamais négatif, jamais None."""
if failed_attempts <= _FREE_ATTEMPTS:
return 0
exponent = failed_attempts - _FREE_ATTEMPTS - 1
return min(_FIRST_LOCKOUT_MINUTES * (2 ** exponent), _MAX_LOCKOUT_MINUTES)
def _parse(dt_str):
if not dt_str:
return None
try:
return datetime.fromisoformat(dt_str)
except ValueError:
return None
def lockout_message(seconds_remaining):
"""Message affiché tel quel dans le formulaire — arrondi à la minute
supérieure (jamais "0 minute" alors qu'il en reste un peu, jamais un
compte de secondes qui oblige à recharger sans arrêt pour voir si
c'est fini)."""
minutes = max(1, math.ceil(seconds_remaining / 60))
unit = "minute" if minutes == 1 else "minutes"
return f"Trop de tentatives. Réessaie dans {minutes} {unit}."
def seconds_locked_remaining(user):
"""> 0 si le compte est actuellement verrouillé (temps restant, en
secondes, arrondi au-dessus pour ne jamais afficher "0 minute" alors
qu'il en reste réellement un peu) ; 0 sinon."""
locked_until = _parse((user or {}).get("locked_until"))
if not locked_until:
return 0
remaining = (locked_until - datetime.now(timezone.utc)).total_seconds()
return max(0, math.ceil(remaining))
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime, timedelta, timezone
from .connection import connect
from .rate_limit import lockout_minutes_for
def record_failed_attempt(user_id):
"""Incrémente le compteur d'échecs de CE compte et, une fois passé les
3 essais libres, pose/allonge son verrouillage (voir rate_limit.py) —
appelé sur un mot de passe OU un code 2FA incorrect, jamais sur un
compte introuvable (rien à incrémenter dans ce cas). Renvoie le
nombre de minutes du verrouillage tout juste posé (0 si encore dans
les essais libres) : l'appelant peut ainsi annoncer IMMÉDIATEMENT
"trop de tentatives" sur CETTE réponse plutôt que de laisser
l'utilisateur découvrir le verrouillage seulement au prochain essai."""
conn = connect()
row = conn.execute("SELECT failed_attempts FROM _users WHERE id = ?", (user_id,)).fetchone()
if not row:
conn.close()
return 0
failed_attempts = row["failed_attempts"] + 1
minutes = lockout_minutes_for(failed_attempts)
locked_until = (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat() if minutes else None
conn.execute(
"UPDATE _users SET failed_attempts = ?, locked_until = ? WHERE id = ?",
(failed_attempts, locked_until, user_id),
)
conn.commit()
conn.close()
return minutes
+12
View File
@@ -0,0 +1,12 @@
from .connection import connect
def reset_failed_attempts(user_id):
"""Remet le compteur à zéro et lève tout verrouillage — appelé dès
qu'une étape de connexion réussit (mot de passe validé ou code 2FA
validé), pour ne jamais punir un utilisateur légitime qui s'est juste
trompé une fois avant de retrouver le bon mot de passe/code."""
conn = connect()
conn.execute("UPDATE _users SET failed_attempts = 0, locked_until = NULL WHERE id = ?", (user_id,))
conn.commit()
conn.close()
+12 -1
View File
@@ -19,7 +19,18 @@ def login():
password = request.form.get("password", "") password = request.form.get("password", "")
next_url = request.form.get("next") or next_url next_url = request.form.get("next") or next_url
user = auth.get_user_by_email(email) user = auth.get_user_by_email(email)
if not user or not auth.verify_password(user, password): # Anti-bruteforce (voir auth/rate_limit.py) : vérifié AVANT le mot
# de passe lui-même — un compte verrouillé ne doit même pas
# apprendre si le mot de passe tapé était le bon.
locked_seconds = auth.seconds_locked_remaining(user) if user else 0
if locked_seconds:
error = auth.lockout_message(locked_seconds)
elif not user or not auth.verify_password(user, password):
if user:
just_locked_minutes = auth.record_failed_attempt(user["id"])
if just_locked_minutes:
error = auth.lockout_message(just_locked_minutes * 60)
if not error:
error = "Adresse email ou mot de passe incorrect." error = "Adresse email ou mot de passe incorrect."
else: else:
# Mot de passe correct, mais pas encore connecté : le second # Mot de passe correct, mais pas encore connecté : le second
+12 -3
View File
@@ -17,10 +17,19 @@ def login_2fa():
error = None error = None
if request.method == "POST": if request.method == "POST":
code = request.form.get("code", "") locked_seconds = auth.seconds_locked_remaining(user)
if not auth.verify_totp(user["totp_secret"], code): if locked_seconds:
error = "Code invalide." error = auth.lockout_message(locked_seconds)
elif not auth.verify_totp(user["totp_secret"], request.form.get("code", "")):
just_locked_minutes = auth.record_failed_attempt(user_id)
error = auth.lockout_message(just_locked_minutes * 60) if just_locked_minutes else "Code invalide."
else: else:
# Connexion réellement aboutie (mot de passe ET code corrects) :
# seul ce point remet le compteur d'échecs à zéro — pas le
# succès du mot de passe seul (voir login.py), sinon un
# attaquant qui connaît déjà le mot de passe aurait un nombre
# illimité d'essais sur le code 2FA.
auth.reset_failed_attempts(user_id)
next_url = session.pop("pending_login_next", "") or "" next_url = session.pop("pending_login_next", "") or ""
session.pop("pending_login_user_id", None) session.pop("pending_login_user_id", None)
session["user_id"] = user_id session["user_id"] = user_id
+8 -3
View File
@@ -18,10 +18,15 @@ def register_2fa():
error = None error = None
if request.method == "POST": if request.method == "POST":
code = request.form.get("code", "") locked_seconds = auth.seconds_locked_remaining(user)
if not auth.verify_totp(user["totp_secret"], code): if locked_seconds:
error = "Code invalide — vérifie l'heure de ton téléphone et réessaie." error = auth.lockout_message(locked_seconds)
elif not auth.verify_totp(user["totp_secret"], request.form.get("code", "")):
just_locked_minutes = auth.record_failed_attempt(user_id)
error = auth.lockout_message(just_locked_minutes * 60) if just_locked_minutes else \
"Code invalide — vérifie l'heure de ton téléphone et réessaie."
else: else:
auth.reset_failed_attempts(user_id)
auth.confirm_totp(user_id) auth.confirm_totp(user_id)
# Un compte "user" (pas "admin") reçoit ICI son unique projet, # Un compte "user" (pas "admin") reçoit ICI son unique projet,
# nommé d'après son adresse email (voir create_user.py — le # nommé d'après son adresse email (voir create_user.py — le
+81
View File
@@ -182,6 +182,87 @@ def test_non_admin_user_cannot_delete_their_only_project(anon_client):
_cleanup_project("nodelete@example.com") _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): 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 """`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 de la session de tests — la création d'un second jeu, déjà exercée par