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
88 lines
3.1 KiB
Python
88 lines
3.1 KiB
Python
"""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"
|