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 .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",
]
+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.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()