Retire « constructeur de formulaire » des exclusions du CLAUDE.md (§ 5) — décision explicite prise en conversation. Un formulaire par édition (COORDINATION choisit les champs actifs parmi neuf : email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir de festival, vœu de compagnon en texte libre, disponibilité/ cotisation déclaratives, photo, commentaire ; nom/prénom toujours collectés) ouvert/fermé à volonté, avec un lien à partager (/inscription, aucun compte requis). Backend : FormTemplate/FormField (migration 0013), quatre nouveaux champs sur Volunteer (photo, companion_wish_note, self_declared_available, willing_to_pay_fee), app/api/forms.py — les deux seuls endpoints publics de toute l'API (GET .../form/public, POST .../form/public/submit), protégés par hCaptcha (app/forms/captcha.py, désactivé sans HCAPTCHA_SECRET_KEY) et un filet de sécurité global par édition (form_submission_limiter). resolve_owned_ids/set_artists/ set_wished_positions déplacés vers app/api/_common.py, partagés avec volunteers.py. Frontend : écran de configuration (/inscriptions) avec lien copiable et bascule ouvert/fermé, formulaire public (/inscription, hors RequireAuth) avec widget hCaptcha chargé dynamiquement, capture photo native (<input capture>), vœux de poste/artiste avec les mêmes règles que le back-end. Réponses du formulaire visibles et éditables depuis la fiche bénévole existante (ConfigVolunteers.tsx). Vérifié : pytest (273 passés), ruff, alembic check, tsc --noEmit, eslint, vite build, smoke (rendu SSR de chaque écran + flux de bout en bout du formulaire en mode démonstration) — tous verts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
70 lines
3.1 KiB
Python
70 lines
3.1 KiB
Python
"""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)
|
|
# Formulaire public d'inscription (`app/api/forms.py`) : pas d'identité
|
|
# fiable à clé (n'importe qui peut soumettre des emails différents à chaque
|
|
# fois, et l'IP réelle du client n'est pas encore transmise par Caddy — voir
|
|
# plus haut) ; hCaptcha est la défense principale contre les robots, ceci
|
|
# n'est qu'un filet de sécurité global côté édition contre un emballement
|
|
# malgré tout (30 inscriptions/5 min est très large pour un usage réel).
|
|
form_submission_limiter = RateLimiter(max_attempts=30, window_seconds=300)
|