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
+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.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)."""
+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"