"""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