É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
This commit is contained in:
AdamThomas
2026-09-09 23:22:35 +02:00
co-authored by Claude Sonnet 5
parent c9dda65361
commit ced922f785
24 changed files with 971 additions and 50 deletions
+93
View File
@@ -0,0 +1,93 @@
"""Dépendances d'authentification et d'autorisation."""
import uuid
from collections.abc import Callable
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlmodel import Session
from app.auth.tokens import decode_access_token
from app.db import get_session
from app.models import Account, Position, Role, Shift
_bearer = HTTPBearer(auto_error=False)
def _unauthorized() -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "error.unauthorized"},
headers={"WWW-Authenticate": "Bearer"},
)
def _forbidden() -> HTTPException:
return HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "error.forbidden"},
)
def get_current_account(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: Session = Depends(get_session),
) -> Account:
if credentials is None or credentials.scheme.lower() != "bearer":
raise _unauthorized()
try:
claims = decode_access_token(credentials.credentials)
account_id = uuid.UUID(claims["sub"])
except (jwt.PyJWTError, KeyError, ValueError) as exc:
raise _unauthorized() from exc
account = session.get(Account, account_id)
if account is None:
raise _unauthorized()
return account
def current_account_in_edition(
edition_id: uuid.UUID,
account: Account = Depends(get_current_account),
) -> Account:
"""Le compte doit appartenir à l'édition de l'URL."""
if account.edition_id != edition_id:
raise _forbidden()
return account
def require_roles(*roles: Role) -> Callable[..., Account]:
def dependency(
account: Account = Depends(current_account_in_edition),
) -> Account:
if account.role not in roles:
raise _forbidden()
return account
return dependency
# Singletons prêts à l'emploi (évite un appel de fonction dans un défaut d'arg).
require_coordinator = require_roles(Role.COORDINATOR)
require_staff = require_roles(Role.COORDINATOR, Role.LEAD)
def ensure_can_write_position(account: Account, position: Position) -> None:
"""COORDINATION partout ; RÉFÉRENT uniquement sur ses propres postes."""
if account.role is Role.COORDINATOR:
return
if account.role is Role.LEAD and position.lead_account_id == account.id:
return
raise _forbidden()
def ensure_can_write_shift(
account: Account, session: Session, shift: Shift
) -> None:
if account.role is Role.COORDINATOR:
return
position = session.get(Position, shift.position_id)
if position is None:
raise _forbidden()
ensure_can_write_position(account, position)