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
@@ -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 +
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user