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
+7 -2
View File
@@ -179,7 +179,10 @@ Le compte porte `password_hash` : défini ⇒ mot de passe, `NULL` ⇒ lien magi
*Mot de passe* — comptes `COORDINATOR` : *Mot de passe* — comptes `COORDINATOR` :
1. `POST /editions/{id}/auth/login` `{email, password}` → JWT + `AccountRead`. 1. `POST /editions/{id}/auth/login` `{email, password}` → JWT + `AccountRead`.
Échec = `401 auth.invalid_credentials` (aucune divulgation). Échec = `401 auth.invalid_credentials` (aucune divulgation). Limité à 5
tentatives/minute par (édition, email) — `429 error.too_many_requests`
au-delà (`app/auth/rate_limit.py`, en mémoire, clé par email — pas par IP,
voir le commentaire du module).
2. `POST /editions/{id}/auth/change-password` `{current_password, new_password}` 2. `POST /editions/{id}/auth/change-password` `{current_password, new_password}`
(compte courant). Hachage **bcrypt**, longueur mini `MIN_LENGTH` (10). (compte courant). Hachage **bcrypt**, longueur mini `MIN_LENGTH` (10).
@@ -188,6 +191,8 @@ Le compte porte `password_hash` : défini ⇒ mot de passe, `NULL` ⇒ lien magi
3. `POST /editions/{id}/auth/magic-link` `{email}` → `202` **constant**. Si un 3. `POST /editions/{id}/auth/magic-link` `{email}` → `202` **constant**. Si un
compte **sans mot de passe** correspond, un `MagicLinkToken` est créé compte **sans mot de passe** correspond, un `MagicLinkToken` est créé
(jeton aléatoire, **haché SHA-256** en base, TTL `magic_link_ttl_minutes`). (jeton aléatoire, **haché SHA-256** en base, TTL `magic_link_ttl_minutes`).
Limité à 3 demandes/5 min par (édition, email) — même
`429 error.too_many_requests`.
4. **Envoi d'email stubé** : le lien est écrit dans les logs (`app.auth`, INFO, 4. **Envoi d'email stubé** : le lien est écrit dans les logs (`app.auth`, INFO,
`jeton=…` — identifiants seulement, aucune donnée nominative). `jeton=…` — identifiants seulement, aucune donnée nominative).
5. `POST /editions/{id}/auth/verify` `{token}` → JWT + `AccountRead`. Jeton à 5. `POST /editions/{id}/auth/verify` `{token}` → JWT + `AccountRead`. Jeton à
@@ -521,7 +526,7 @@ Aucune dépendance locale requise hors Docker.
| 16 | Bénévole : l'email n'est plus unique (couple, famille) — identification par (email, nom + prénom) dans l'import et l'API, plus de rejet 409 à la création manuelle | à valider | | 16 | Bénévole : l'email n'est plus unique (couple, famille) — identification par (email, nom + prénom) dans l'import et l'API, plus de rejet 409 à la création manuelle | à valider |
| 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider | | 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider |
| 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider | | 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider |
| 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend | à valider | | 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+3
View File
@@ -25,6 +25,7 @@ from app.api.schemas import (
from app.auth.deps import get_current_account 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.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.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.auth.tokens import create_access_token, hash_token
from app.config import get_settings from app.config import get_settings
from app.db import get_session from app.db import get_session
@@ -76,6 +77,7 @@ def login(
payload: LoginRequest, payload: LoginRequest,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> TokenResponse: ) -> TokenResponse:
login_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
account = _account_by_email(session, edition_id, payload.email) account = _account_by_email(session, edition_id, payload.email)
if ( if (
account is None account is None
@@ -131,6 +133,7 @@ def request_magic_link(
"""Émet un lien magique si un compte **sans mot de passe** correspond. """É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 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).""" 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) account = _account_by_email(session, edition_id, payload.email)
if account is not None and account.password_hash is None: if account is not None and account.password_hash is None:
settings = get_settings() 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.unauthorized": "Authentification requise",
"error.validation_failed": "La validation a échoué", "error.validation_failed": "La validation a échoué",
"error.conflict": "Conflit avec une ressource existante.", "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.", "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.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.", "day_part.duplicate_code": "Un créneau type porte déjà ce code dans l'édition.",
+11
View File
@@ -7,6 +7,7 @@ from sqlmodel import Session, SQLModel, create_engine, select
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata) from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
from app.auth.deps import get_current_account from app.auth.deps import get_current_account
from app.auth.rate_limit import login_limiter, magic_link_limiter
from app.config import get_settings from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.main import app from app.main import app
@@ -23,6 +24,16 @@ def _never_use_real_smtp(monkeypatch):
get_settings.cache_clear() get_settings.cache_clear()
@pytest.fixture(autouse=True)
def _reset_rate_limiters():
"""Les limiteurs de `/auth/login` et `/auth/magic-link` sont des
singletons en mémoire (voir `app/auth/rate_limit.py`) : un test qui en
déclenche un ne doit pas polluer les suivants."""
login_limiter.reset()
magic_link_limiter.reset()
yield
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def engine(): def engine():
"""Vraie base PostgreSQL de test (voir docker/postgres-init).""" """Vraie base PostgreSQL de test (voir docker/postgres-init)."""
+87
View File
@@ -0,0 +1,87 @@
"""Limiteur de tentatives sur `/auth/login` et `/auth/magic-link` — voir
`app/auth/rate_limit.py`."""
import pytest
from fastapi import HTTPException
from app.auth.rate_limit import RateLimiter
from app.models import Role
PASSWORD = "coordination-2026"
def _url(edition, suffix):
return f"/editions/{edition.id}/auth/{suffix}"
def test_rate_limiter_blocks_beyond_max_attempts():
limiter = RateLimiter(max_attempts=3, window_seconds=60)
for _ in range(3):
limiter.hit("k")
with pytest.raises(HTTPException) as exc_info:
limiter.hit("k")
assert exc_info.value.status_code == 429
def test_rate_limiter_keys_are_independent():
limiter = RateLimiter(max_attempts=1, window_seconds=60)
limiter.hit("a")
# Une autre clé n'est pas affectée par les tentatives sur "a".
limiter.hit("b")
def test_rate_limiter_forgets_hits_outside_the_window(monkeypatch):
limiter = RateLimiter(max_attempts=1, window_seconds=10)
now = [1000.0]
monkeypatch.setattr("app.auth.rate_limit.time.monotonic", lambda: now[0])
limiter.hit("k")
now[0] += 11 # au-delà de la fenêtre de 10s
limiter.hit("k") # ne lève pas : la première tentative est oubliée
def test_login_is_rate_limited_after_five_attempts(raw_client, edition, make_account):
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
for _ in range(5):
resp = raw_client.post(
_url(edition, "login"),
json={"email": "admin@e.fr", "password": "faux"},
)
assert resp.status_code == 401
resp = raw_client.post(
_url(edition, "login"),
json={"email": "admin@e.fr", "password": "faux"},
)
assert resp.status_code == 429
assert resp.json()["detail"]["code"] == "error.too_many_requests"
# Même le bon mot de passe est bloqué tant que la fenêtre n'est pas passée.
resp = raw_client.post(
_url(edition, "login"),
json={"email": "admin@e.fr", "password": PASSWORD},
)
assert resp.status_code == 429
def test_login_rate_limit_is_scoped_per_email(raw_client, edition, make_account):
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
make_account(Role.COORDINATOR, email="autre@e.fr", password=PASSWORD)
for _ in range(5):
raw_client.post(
_url(edition, "login"),
json={"email": "admin@e.fr", "password": "faux"},
)
# Un autre compte n'est pas affecté par les tentatives sur le premier.
resp = raw_client.post(
_url(edition, "login"),
json={"email": "autre@e.fr", "password": PASSWORD},
)
assert resp.status_code == 200
def test_magic_link_is_rate_limited_after_three_requests(raw_client, edition, make_account):
make_account(Role.VOLUNTEER, email="v@e.fr")
for _ in range(3):
resp = raw_client.post(_url(edition, "magic-link"), json={"email": "v@e.fr"})
assert resp.status_code == 202
resp = raw_client.post(_url(edition, "magic-link"), json={"email": "v@e.fr"})
assert resp.status_code == 429
assert resp.json()["detail"]["code"] == "error.too_many_requests"
+5 -3
View File
@@ -33,9 +33,11 @@ serveur** (service système, hors de ce dépôt) reçoit le trafic public sur
même origine via `/api/*` (voir le vhost Caddy, étape 5) — à ne renseigner même origine via `/api/*` (voir le vhost Caddy, étape 5) — à ne renseigner
que si un autre client (domaine séparé, appli mobile) doit appeler l'API que si un autre client (domaine séparé, appli mobile) doit appeler l'API
directement. directement.
- Aucun rate-limiting sur `/auth/login` ni `/auth/magic-link` à ce stade - `/auth/login` et `/auth/magic-link` sont limités en tentatives par email
(pas de verrouillage de compte, pas de throttling) — acceptable pour une (5/min et 3/5 min) — voir `app/auth/rate_limit.py`. Limité par email et
petite équipe connue, mais à surveiller si l'URL circule plus largement. non par IP (le VPS ne transmet pas encore l'IP réelle du client à travers
Caddy) : suffisant contre la force brute sur un compte connu, pas contre
une attaque distribuée sur de nombreux emails différents.
## Étape 0 — pousser le dépôt sur GitLab ## Étape 0 — pousser le dépôt sur GitLab
+1
View File
@@ -12,6 +12,7 @@
"error.forbidden": "Accès refusé", "error.forbidden": "Accès refusé",
"error.unauthorized": "Authentification requise", "error.unauthorized": "Authentification requise",
"error.validation_failed": "La validation a échoué (règle bloquante)", "error.validation_failed": "La validation a échoué (règle bloquante)",
"error.too_many_requests": "Trop de tentatives, réessayez dans un instant",
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau", "assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau",
"assignment.wrong_edition": "Bénévole ou créneau hors de cette édition", "assignment.wrong_edition": "Bénévole ou créneau hors de cette édition",
"shift.ends_not_after_starts": "La fin doit être après le début", "shift.ends_not_after_starts": "La fin doit être après le début",