Files
benevoles/backend/app/api/auth.py
T
AdamThomasandClaude Sonnet 5 0ede409841 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
2026-09-16 08:19:27 +02:00

186 lines
6.2 KiB
Python

"""Authentification.
Deux systèmes, selon le compte :
- **mot de passe** (`POST .../auth/login`) pour les comptes qui en ont un —
la coordination ;
- **lien magique** (`.../auth/magic-link` puis `.../auth/verify`) pour les
comptes sans mot de passe — référents et bénévoles.
"""
import logging
import uuid
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from sqlalchemy import func
from sqlmodel import Session, select
from app.api.schemas import (
AccountRead,
ChangePasswordRequest,
LoginRequest,
MagicLinkRequest,
TokenResponse,
VerifyRequest,
)
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
from app.email import render_magic_link, send
from app.models import Account, MagicLinkToken, local_now
router = APIRouter(tags=["auth"])
logger = logging.getLogger("app.auth")
def _account_by_email(
session: Session, edition_id: uuid.UUID, email: str
) -> Account | None:
return session.exec(
select(Account)
.where(Account.edition_id == edition_id)
.where(func.lower(Account.email) == email.strip().lower())
).first()
def _issue(account: Account) -> TokenResponse:
return TokenResponse(
access_token=create_access_token(account),
token_type="bearer",
account=AccountRead.model_validate(account),
)
def _invalid_credentials() -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "auth.invalid_credentials"},
)
def _invalid_token() -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "auth.invalid_token"},
)
# --------------------------------------------------------------------------- #
# Mot de passe (admins) #
# --------------------------------------------------------------------------- #
@router.post("/editions/{edition_id}/auth/login", response_model=TokenResponse)
def login(
edition_id: uuid.UUID,
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
or account.password_hash is None
or not verify_password(payload.password, account.password_hash)
):
raise _invalid_credentials()
return _issue(account)
@router.post(
"/editions/{edition_id}/auth/change-password",
status_code=status.HTTP_204_NO_CONTENT,
)
def change_password(
edition_id: uuid.UUID,
payload: ChangePasswordRequest,
account: Account = Depends(get_current_account),
session: Session = Depends(get_session),
) -> None:
if account.edition_id != edition_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "error.forbidden"},
)
if account.password_hash is None or not verify_password(
payload.current_password, account.password_hash
):
raise _invalid_credentials()
if len(payload.new_password) < MIN_LENGTH:
raise HTTPException(
status_code=422,
detail={"code": "auth.password_too_short", "min": MIN_LENGTH},
)
account.password_hash = hash_password(payload.new_password)
session.add(account)
session.commit()
# --------------------------------------------------------------------------- #
# Lien magique (référents, bénévoles) #
# --------------------------------------------------------------------------- #
@router.post(
"/editions/{edition_id}/auth/magic-link",
status_code=status.HTTP_202_ACCEPTED,
)
def request_magic_link(
edition_id: uuid.UUID,
payload: MagicLinkRequest,
background: BackgroundTasks,
session: Session = Depends(get_session),
) -> dict[str, str]:
"""É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()
raw = issue_magic_link_token(session, account)
session.commit()
subject, body = render_magic_link(
link=build_magic_link_url(raw),
minutes=settings.magic_link_ttl_minutes,
)
background.add_task(send, account.email, subject, body)
if not settings.email_enabled:
# Aide au dev sans SMTP : le jeton (non nominatif) est journalisé.
logger.info(
"Lien magique (email désactivé) — compte=%s jeton=%s",
account.id,
raw,
)
return {"status": "accepted"}
@router.post("/editions/{edition_id}/auth/verify", response_model=TokenResponse)
def verify_magic_link(
edition_id: uuid.UUID,
payload: VerifyRequest,
session: Session = Depends(get_session),
) -> TokenResponse:
token = session.exec(
select(MagicLinkToken).where(
MagicLinkToken.token_hash == hash_token(payload.token.strip())
)
).first()
if (
token is None
or token.used_at is not None
or token.expires_at < local_now()
):
raise _invalid_token()
account = session.get(Account, token.account_id)
if account is None or account.edition_id != edition_id:
raise _invalid_token()
token.used_at = local_now()
session.commit()
session.refresh(account)
return _issue(account)
@router.get("/auth/me", response_model=AccountRead)
def read_me(account: Account = Depends(get_current_account)) -> Account:
return account