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
+3
View File
@@ -25,6 +25,7 @@ from app.api.schemas import (
from app.auth.deps import get_current_account
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
from app.auth.passwords import MIN_LENGTH, hash_password, verify_password
from app.auth.rate_limit import login_limiter, magic_link_limiter
from app.auth.tokens import create_access_token, hash_token
from app.config import get_settings
from app.db import get_session
@@ -76,6 +77,7 @@ def login(
payload: LoginRequest,
session: Session = Depends(get_session),
) -> TokenResponse:
login_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
account = _account_by_email(session, edition_id, payload.email)
if (
account is None
@@ -131,6 +133,7 @@ def request_magic_link(
"""Émet un lien magique si un compte **sans mot de passe** correspond.
Réponse identique dans tous les cas (pas de divulgation). L'email part en
tâche de fond (une erreur SMTP est journalisée, pas remontée)."""
magic_link_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
account = _account_by_email(session, edition_id, payload.email)
if account is not None and account.password_hash is None:
settings = get_settings()
+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)
+1
View File
@@ -5,6 +5,7 @@
"error.unauthorized": "Authentification requise",
"error.validation_failed": "La validation a échoué",
"error.conflict": "Conflit avec une ressource existante.",
"error.too_many_requests": "Trop de tentatives, réessayez dans un instant",
"edition.dates_inconsistent": "La date de fin doit être postérieure ou égale à la date de début.",
"day_part.code_required": "Le code du créneau type ne peut pas être vide.",
"day_part.duplicate_code": "Un créneau type porte déjà ce code dans l'édition.",