Files
benevoles/backend/app/api/auth.py
T
AdamThomasandClaude Sonnet 5 ced922f785 Étape 7 : authentification par lien magique + JWT + rôles
- app/auth/ : jetons (lien magique aléatoire haché SHA-256, JWT HS256),
  dépendances get_current_account / current_account_in_edition / require_roles
  + require_coordinator / require_staff, et ensure_can_write_position/_shift
- Endpoints app/api/auth.py : POST .../auth/magic-link (202 constant, pas de
  divulgation), POST .../auth/verify (JWT, jeton à usage unique), GET /auth/me
- Envoi d'email stubé : le lien est journalisé (logger app.auth, identifiants
  seulement, aucune donnée nominative)
- app/api/accounts.py : CRUD des comptes réservé à la COORDINATION
- Rôles appliqués sur toute l'API :
  COORDINATION accès complet ; RÉFÉRENT lecture globale + écriture sur ses
  seuls postes/créneaux/affectations ; BÉNÉVOLE sa fiche et ses affectations,
  aucune écriture
- imports.py aligné sur la dépendance get_edition (404 avant 403)
- Secret JWT porté à >= 32 octets (config, .env.example, docker-compose)
- seed : crée un compte VOLUNTEER pour chaque bénévole avec email (104 comptes)
- conftest : fixtures make_account / coordinator / as_account / raw_client ;
  la suite existante s'exécute en COORDINATION
- 13 tests d'authentification + de rôles ; 119 tests au total ; alembic check
  toujours vert (Account / MagicLinkToken déjà dans la migration 0001)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-09 23:22:35 +02:00

104 lines
3.2 KiB
Python

"""Endpoints d'authentification : lien magique, vérification, profil courant."""
import datetime as dt
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlmodel import Session, select
from app.api.schemas import (
AccountRead,
MagicLinkRequest,
TokenResponse,
VerifyRequest,
)
from app.auth.deps import get_current_account
from app.auth.tokens import create_access_token, hash_token, new_raw_token
from app.config import get_settings
from app.db import get_session
from app.models import Account, MagicLinkToken, local_now
router = APIRouter(tags=["auth"])
logger = logging.getLogger("app.auth")
@router.post(
"/editions/{edition_id}/auth/magic-link",
status_code=status.HTTP_202_ACCEPTED,
)
def request_magic_link(
edition_id: uuid.UUID,
payload: MagicLinkRequest,
session: Session = Depends(get_session),
) -> dict[str, str]:
"""Émet un lien magique si un compte correspond. Réponse identique dans
tous les cas (pas de divulgation de l'existence d'un compte)."""
account = session.exec(
select(Account)
.where(Account.edition_id == edition_id)
.where(func.lower(Account.email) == payload.email.strip().lower())
).first()
if account is not None:
raw = new_raw_token()
token = MagicLinkToken(
account_id=account.id,
token_hash=hash_token(raw),
expires_at=local_now()
+ dt.timedelta(minutes=get_settings().magic_link_ttl_minutes),
)
session.add(token)
session.commit()
# STUB : en production, ce lien part par email. Ici on le journalise
# (identifiants uniquement, aucune donnée nominative).
logger.info(
"STUB lien magique — compte=%s édition=%s expire=%s jeton=%s",
account.id,
edition_id,
token.expires_at.isoformat(),
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 HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "auth.invalid_token"},
)
account = session.get(Account, token.account_id)
if account is None or account.edition_id != edition_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "auth.invalid_token"},
)
token.used_at = local_now()
session.commit()
session.refresh(account)
return TokenResponse(
access_token=create_access_token(account),
token_type="bearer",
account=AccountRead.model_validate(account),
)
@router.get("/auth/me", response_model=AccountRead)
def read_me(account: Account = Depends(get_current_account)) -> Account:
return account