From 989e899a96ba4bb3fc6513bedec918002226dd6e Mon Sep 17 00:00:00 2001 From: william Date: Fri, 28 Aug 2026 20:09:35 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20un=20anti-bruteforce=20(3=20essais=20l?= =?UTF-8?q?ibres=20puis=205/10/20/40=20min,=20plafonn=C3=A9=20=C3=A0=201h)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- auth/__init__.py | 5 +++ auth/ensure_schema.py | 10 +++++ auth/rate_limit.py | 50 +++++++++++++++++++++ auth/record_failed_attempt.py | 30 +++++++++++++ auth/reset_failed_attempts.py | 12 ++++++ routes/auth/login.py | 15 ++++++- routes/auth/login_2fa.py | 15 +++++-- routes/auth/register_2fa.py | 11 +++-- tests/test_auth.py | 81 +++++++++++++++++++++++++++++++++++ 9 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 auth/rate_limit.py create mode 100644 auth/record_failed_attempt.py create mode 100644 auth/reset_failed_attempts.py diff --git a/auth/__init__.py b/auth/__init__.py index 94bd33e1..c25a4644 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -16,6 +16,9 @@ from .verify_totp import verify_totp from .set_project_slug import set_project_slug from .password_strength import password_strength, MIN_SCORE_REQUIRED 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__ = [ "users_db_path", "ensure_users_schema", "is_first_user", @@ -24,4 +27,6 @@ __all__ = [ "verify_password", "confirm_totp", "verify_totp", "set_project_slug", "password_strength", "MIN_SCORE_REQUIRED", "totp_provisioning_uri", "totp_qrcode_svg", + "lockout_minutes_for", "seconds_locked_remaining", "lockout_message", + "record_failed_attempt", "reset_failed_attempts", ] diff --git a/auth/ensure_schema.py b/auth/ensure_schema.py index db4fe0b8..b1f3cef0 100644 --- a/auth/ensure_schema.py +++ b/auth/ensure_schema.py @@ -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.close() diff --git a/auth/rate_limit.py b/auth/rate_limit.py new file mode 100644 index 00000000..2467ef86 --- /dev/null +++ b/auth/rate_limit.py @@ -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)) diff --git a/auth/record_failed_attempt.py b/auth/record_failed_attempt.py new file mode 100644 index 00000000..46b79ef5 --- /dev/null +++ b/auth/record_failed_attempt.py @@ -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 diff --git a/auth/reset_failed_attempts.py b/auth/reset_failed_attempts.py new file mode 100644 index 00000000..057ff22e --- /dev/null +++ b/auth/reset_failed_attempts.py @@ -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() diff --git a/routes/auth/login.py b/routes/auth/login.py index f172a572..a5ae1116 100644 --- a/routes/auth/login.py +++ b/routes/auth/login.py @@ -19,8 +19,19 @@ def login(): password = request.form.get("password", "") next_url = request.form.get("next") or next_url user = auth.get_user_by_email(email) - if not user or not auth.verify_password(user, password): - error = "Adresse email ou mot de passe incorrect." + # 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." else: # Mot de passe correct, mais pas encore connecté : le second # facteur (TOTP) reste à vérifier — voir login_2fa.py. Session diff --git a/routes/auth/login_2fa.py b/routes/auth/login_2fa.py index dc9f6bd3..f994e1be 100644 --- a/routes/auth/login_2fa.py +++ b/routes/auth/login_2fa.py @@ -17,10 +17,19 @@ def login_2fa(): error = None if request.method == "POST": - code = request.form.get("code", "") - if not auth.verify_totp(user["totp_secret"], code): - error = "Code invalide." + locked_seconds = auth.seconds_locked_remaining(user) + if locked_seconds: + 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: + # 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 "" session.pop("pending_login_user_id", None) session["user_id"] = user_id diff --git a/routes/auth/register_2fa.py b/routes/auth/register_2fa.py index b4f34fb0..d6717519 100644 --- a/routes/auth/register_2fa.py +++ b/routes/auth/register_2fa.py @@ -18,10 +18,15 @@ def register_2fa(): error = None if request.method == "POST": - code = request.form.get("code", "") - if not auth.verify_totp(user["totp_secret"], code): - error = "Code invalide — vérifie l'heure de ton téléphone et réessaie." + locked_seconds = auth.seconds_locked_remaining(user) + if locked_seconds: + 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: + auth.reset_failed_attempts(user_id) auth.confirm_totp(user_id) # Un compte "user" (pas "admin") reçoit ICI son unique projet, # nommé d'après son adresse email (voir create_user.py — le diff --git a/tests/test_auth.py b/tests/test_auth.py index bcc79764..df99f899 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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