diff --git a/CLAUDE.md b/CLAUDE.md index 9051b49..3016f77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,7 +179,10 @@ Le compte porte `password_hash` : défini ⇒ mot de passe, `NULL` ⇒ lien magi *Mot de passe* — comptes `COORDINATOR` : 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}` (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 compte **sans mot de passe** correspond, un `MagicLinkToken` est créé (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, `jeton=…` — identifiants seulement, aucune donnée nominative). 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 | | 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 | -| 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 de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index cf5f732..a4c493b 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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() diff --git a/backend/app/auth/rate_limit.py b/backend/app/auth/rate_limit.py new file mode 100644 index 0000000..065cc6b --- /dev/null +++ b/backend/app/auth/rate_limit.py @@ -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) diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index e3d113b..b4c3100 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -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.", diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index c9c2053..24075fa 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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.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.db import get_session from app.main import app @@ -23,6 +24,16 @@ def _never_use_real_smtp(monkeypatch): 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") def engine(): """Vraie base PostgreSQL de test (voir docker/postgres-init).""" diff --git a/backend/tests/test_rate_limit.py b/backend/tests/test_rate_limit.py new file mode 100644 index 0000000..e91d259 --- /dev/null +++ b/backend/tests/test_rate_limit.py @@ -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" diff --git a/deploy/README.md b/deploy/README.md index 0ddf1fd..40b234c 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 que si un autre client (domaine séparé, appli mobile) doit appeler l'API directement. -- Aucun rate-limiting sur `/auth/login` ni `/auth/magic-link` à ce stade - (pas de verrouillage de compte, pas de throttling) — acceptable pour une - petite équipe connue, mais à surveiller si l'URL circule plus largement. +- `/auth/login` et `/auth/magic-link` sont limités en tentatives par email + (5/min et 3/5 min) — voir `app/auth/rate_limit.py`. Limité par email et + 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 diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 1e32f84..3e66716 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -12,6 +12,7 @@ "error.forbidden": "Accès refusé", "error.unauthorized": "Authentification requise", "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.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",