"""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 edition_logo, 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) ): # Cause réelle dans les logs seulement — la réponse reste la même # (pas de divulgation). Jamais l'email saisi. if account is None: reason = "aucun compte pour cet email" elif account.password_hash is None: reason = f"compte {account.id} sans mot de passe (lien magique)" else: reason = f"mot de passe incorrect pour le compte {account.id}" logger.info("Connexion refusée (édition %s) : %s", edition_id, reason) raise _invalid_credentials() logger.info("Connexion par mot de passe : compte %s (édition %s)", account.id, edition_id) 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 None: logger.info( "Lien magique demandé sans compte correspondant (édition %s) — rien envoyé " "(un email sur une fiche bénévole ne crée pas d'accès)", edition_id, ) elif account.password_hash is not None: logger.info( "Lien magique demandé pour le compte %s, qui a un mot de passe — rien envoyé", account.id, ) if account is not None and account.password_hash is None: logger.info("Lien magique émis pour le compte %s (édition %s)", account.id, edition_id) 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, logo=edition_logo(session, edition_id) ) 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() ): if token is None: reason = "jeton inconnu" elif token.used_at is not None: reason = f"jeton déjà utilisé (compte {token.account_id})" else: reason = f"jeton expiré (compte {token.account_id})" logger.info("Lien magique refusé (édition %s) : %s", edition_id, reason) raise _invalid_token() account = session.get(Account, token.account_id) if account is None or account.edition_id != edition_id: logger.info( "Lien magique refusé (édition %s) : compte %s absent ou d'une autre édition", edition_id, token.account_id, ) raise _invalid_token() token.used_at = local_now() session.commit() session.refresh(account) logger.info("Connexion par lien magique : compte %s (édition %s)", account.id, edition_id) return _issue(account) @router.get("/auth/me", response_model=AccountRead) def read_me(account: Account = Depends(get_current_account)) -> Account: return account