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:
AdamThomas
2026-09-16 08:19:27 +02:00
co-authored by Claude Sonnet 5
parent 89e74e5ec7
commit 0ede409841
8 changed files with 177 additions and 5 deletions
+62
View File
@@ -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)