Limiteur de tentatives sur /auth/login et /auth/magic-link
Suite de la revue de sécurité (commit précédent) : ni /auth/login (mot de passe COORDINATION) ni /auth/magic-link n'avaient de limite de tentatives — aucun verrou de compte, aucun throttling, avant ouverture au public. `app/auth/rate_limit.py` : limiteur en mémoire (un seul processus uvicorn, cf. Dockerfile), à créneau glissant, par clé (édition, email) plutôt que par IP — le VPS ne transmet pas encore l'IP réelle du client à travers Caddy, et le risque réel ici est de deviner le mot de passe d'un compte connu (peu de comptes protégés par mot de passe), pas une attaque distribuée sur des emails au hasard. - `/auth/login` : 5 tentatives/minute → 429 `error.too_many_requests`. - `/auth/magic-link` : 3 demandes/5 min (évite aussi le spam d'emails vers une victime ou vers le SMTP). Fixture `_reset_rate_limiters` (autouse) pour ne pas faire fuiter l'état d'un test à l'autre. Testé : dépassement, indépendance des clés, expiration de la fenêtre, scénarios bout en bout sur les deux endpoints. Non inclus dans ce commit (édités en parallèle par l'utilisateur, laissés en l'état) : backend/app/seed.py, frontend/tailwind.config.js. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
89e74e5ec7
commit
0ede409841
@@ -0,0 +1,62 @@
|
||||
"""Limiteur de tentatives, en mémoire — protège `/auth/login` (force brute
|
||||
sur le mot de passe COORDINATION) et `/auth/magic-link` (spam d'emails vers
|
||||
une victime, ou vers le SMTP) faute d'un reverse-proxy qui le ferait à notre
|
||||
place.
|
||||
|
||||
Clé par **email seul**, pas par IP : le service tourne derrière Caddy sur le
|
||||
VPS (voir `deploy/`), qui ne transmet pas l'IP réelle du client par défaut —
|
||||
il faudrait faire confiance à `X-Forwarded-For` en configurant un proxy de
|
||||
confiance, ce que ce déploiement ne fait pas encore. Limiter par email suffit
|
||||
au risque réel ici : il n'y a qu'un très petit nombre de comptes protégés par
|
||||
mot de passe (la coordination), donc l'attaque à contrer est « deviner le
|
||||
mot de passe d'un compte connu », pas une attaque distribuée sur des emails
|
||||
au hasard.
|
||||
|
||||
En mémoire, un seul processus uvicorn (voir `Dockerfile`, pas de
|
||||
`--workers`) : un compteur partagé entre threads (les endpoints sont
|
||||
synchrones, exécutés dans un pool de threads par Starlette) suffit, pas
|
||||
besoin de Redis pour ce volume."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, max_attempts: int, window_seconds: float) -> None:
|
||||
self.max_attempts = max_attempts
|
||||
self.window_seconds = window_seconds
|
||||
self._hits: dict[str, list[float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def hit(self, key: str) -> None:
|
||||
"""Enregistre une tentative pour `key` ; lève 429 si la limite du
|
||||
créneau glissant est dépassée."""
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
recent = [
|
||||
t for t in self._hits.get(key, []) if now - t < self.window_seconds
|
||||
]
|
||||
if len(recent) >= self.max_attempts:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={"code": "error.too_many_requests"},
|
||||
)
|
||||
recent.append(now)
|
||||
self._hits[key] = recent
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Tests uniquement."""
|
||||
with self._lock:
|
||||
self._hits.clear()
|
||||
|
||||
|
||||
# 5 tentatives/minute : large pour un vrai utilisateur qui se trompe, serré
|
||||
# pour de la force brute (bcrypt ralentit déjà chaque essai, ceci coupe
|
||||
# l'automatisation par-dessus).
|
||||
login_limiter = RateLimiter(max_attempts=5, window_seconds=60)
|
||||
# 3 demandes/5 min : un bénévole qui n'a pas reçu son email réessaie deux ou
|
||||
# trois fois ; au-delà c'est soit un abus, soit un email qui n'arrivera pas
|
||||
# plus vite en insistant.
|
||||
magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300)
|
||||
Reference in New Issue
Block a user