- Lien d'inscription protégé par un jeton (FormTemplate.access_token, /inscription/<token>), avec régénération depuis l'écran de configuration. - Reçus fiscaux mécénat (cerfa 11580*05) : un PDF par bénévole ayant payé en mécénat, donateur/montant (chiffres et toutes lettres en français, app/mecenat/number_words.py)/date complétés par-dessus le modèle officiel. - Jeu de démonstration : bénévoles avec cotisation classique ou mécénat payée. - Infobulles (HelpTip et détail de cellule de la grille des besoins) repositionnées en position: fixed via JS, bornées à l'écran. - Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le jeton d'inscription), sujet retiré du log de remise des emails (pouvait porter un nom de bénévole), photo du formulaire public revalidée par décodage réel avant stockage, export Excel protégé contre l'injection de formule, sondage du statut de paiement limité en fréquence, pyjwt mis à jour. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
188 lines
6.3 KiB
Python
188 lines
6.3 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 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)
|
|
):
|
|
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, 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()
|
|
):
|
|
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
|