Lien d'inscription protégé par jeton, reçus fiscaux mécénat, infobulles et revue de sécurité
ci-cd / build-backend (push) Successful in 1m12s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s

- 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>
This commit is contained in:
AdamThomas
2026-10-02 07:47:17 +02:00
co-authored by Claude Sonnet 5
parent a2b748566b
commit 096b7cd26a
44 changed files with 1228 additions and 156 deletions
+73 -13
View File
@@ -3,11 +3,15 @@
Deux familles d'endpoints sous le même routeur :
- configuration (COORDINATION) : quels champs sont activés, formulaire
ouvert ou fermé, identifiants SumUp de paiement de la cotisation ;
- publics (**aucune authentification**) : consultation de la configuration
et soumission (protégées par hCaptcha, `app/forms/captcha.py`, et un filet
de sécurité global, `app/auth/rate_limit.py:form_submission_limiter`), et
le webhook SumUp (`/public/sumup-webhook`, appelé par SumUp lui-même — pas
de hCaptcha possible serveur à serveur, voir `sumup_webhook` plus bas).
- publics (**aucune authentification**, mais — sauf le webhook — protégés
par un `token` requis en plus de `edition_id`, voir `_require_access_token`
et `FormTemplate.access_token` : `/inscription` seul, sans le jeton donné
par la coordination, ne mène nulle part) : consultation de la
configuration et soumission (protégées aussi par hCaptcha,
`app/forms/captcha.py`, et un filet de sécurité global,
`app/auth/rate_limit.py:form_submission_limiter`), et le webhook SumUp
(`/public/sumup-webhook`, appelé par SumUp lui-même — pas de hCaptcha ni
de jeton possible serveur à serveur, voir `sumup_webhook` plus bas).
Nom et prénom sont toujours collectés (indispensables à la création du
bénévole) : ils ne font pas partie des champs activables, contrairement aux
@@ -21,8 +25,10 @@ coordination, faute de savoir si la personne visée est déjà inscrite (voir
CLAUDE.md § 2, `Volunteer`)."""
import datetime as dt
import io
import json
import logging
import secrets
import uuid
from fastapi import (
@@ -36,6 +42,7 @@ from fastapi import (
UploadFile,
status,
)
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
@@ -64,11 +71,11 @@ from app.api.schemas import (
SumUpWebhookPayload,
)
from app.auth.deps import require_coordinator
from app.auth.rate_limit import form_submission_limiter
from app.auth.rate_limit import form_submission_limiter, pending_status_limiter
from app.config import get_settings
from app.db import get_session
from app.email import (
edition_logo_url,
edition_logo,
render_registration_confirmation,
render_registration_paid_confirmation,
send,
@@ -154,9 +161,20 @@ def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormT
description=template.description,
sumup_merchant_code=template.sumup_merchant_code,
sumup_api_key_set=bool(template.sumup_api_key),
access_token=template.access_token,
)
def _require_access_token(template: FormTemplate, token: str) -> None:
"""`/inscription` seul, sans le bon jeton, ne doit rien révéler —
404 générique (`error.not_found`), jamais un message distinguant
« mauvais jeton » de « aucun formulaire ». Comparaison à temps constant
(`secrets.compare_digest`) : un jeton de longueur correcte mais faux ne
doit pas se deviner caractère par caractère via le temps de réponse."""
if not secrets.compare_digest(token, template.access_token):
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
@router.get("", response_model=FormTemplateRead)
def get_form(
edition: Edition = Depends(get_edition),
@@ -200,6 +218,23 @@ def update_form(
return _template_read(template, _enabled_fields(session, edition.id))
@router.post("/regenerate-token", response_model=FormTemplateRead)
def regenerate_access_token(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> FormTemplateRead:
"""Révoque le lien d'inscription partagé jusqu'ici : un nouveau jeton
remplace l'ancien, qui cesse aussitôt de fonctionner (utile si le lien a
fuité). Rien d'autre n'est affecté — les inscriptions déjà reçues, en
attente de paiement ou en cours de paiement restent valables."""
template = _get_or_create_template(session, edition.id)
template.access_token = secrets.token_urlsafe(16)
session.add(template)
session.commit()
return _template_read(template, _enabled_fields(session, edition.id))
# --------------------------------------------------------------------------- #
# Types de paiement (COORDINATION) — voir `FormPaymentOption` #
# --------------------------------------------------------------------------- #
@@ -323,12 +358,15 @@ async def set_logo(
@router.get("/logo")
def get_logo(
token: str,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> Response:
"""Public (aucune authentification) : affiché sur `/inscription`, comme
"""Public (aucune authentification, mais protégé par `token` — voir
`_require_access_token`) : affiché sur `/inscription/<token>`, comme
`GET .../form/public` — voir le commentaire du module."""
template = _get_or_create_template(session, edition.id)
_require_access_token(template, token)
if template.logo is None:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return Response(
@@ -342,10 +380,12 @@ def get_logo(
# --------------------------------------------------------------------------- #
@router.get("/public", response_model=PublicFormConfig)
def get_public_form(
token: str,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> PublicFormConfig:
template = _get_or_create_template(session, edition.id)
_require_access_token(template, token)
fields = _enabled_fields(session, edition.id)
positions: list[PublicPositionOption] = []
@@ -411,7 +451,15 @@ def _prune_stale_pending(session: Session, edition_id: uuid.UUID) -> None:
async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
"""Validation partagée par l'inscription immédiate et l'inscription en
attente de paiement — `None` si aucun fichier fourni."""
attente de paiement — `None` si aucun fichier fourni. `Content-Type` est
un en-tête fourni par l'appelant, jamais vérifié par lui-même (ce
endpoint est public, sans authentification) : un fichier qui ne se
décode pas comme une vraie image malgré un `Content-Type` correct
(`image/png`/`image/jpeg` revendiqué) est rejeté ici plutôt que de
planter plus tard la génération des badges/feuilles de poste
(`app/badges/pdf.py`/`app/sheets/pdf.py`, qui lisent ces octets sans
filet) — même validation que l'import d'image des badges
(`app/api/badges.py:_pixel_size`, réservé à la coordination)."""
if photo is None or not photo.filename:
return None
content_type = photo.content_type or ""
@@ -422,7 +470,16 @@ async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
content = await photo.read()
if len(content) > _MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
return (content, content_type) if content else None
if not content:
return None
try:
with Image.open(io.BytesIO(content)) as img:
img.verify()
except (UnidentifiedImageError, OSError) as exc:
raise HTTPException(
status_code=422, detail={"code": "form.photo_type_not_supported"}
) from exc
return content, content_type
def _filter_owned_ids(
@@ -498,6 +555,7 @@ async def submit_public_form(
background: BackgroundTasks,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
access_token: str = Form(...),
first_name: str = Form(...),
last_name: str = Form(...),
hcaptcha_token: str = Form(...),
@@ -517,6 +575,7 @@ async def submit_public_form(
_prune_stale_pending(session, edition.id)
template = _get_or_create_template(session, edition.id)
_require_access_token(template, access_token)
if not template.is_open:
raise HTTPException(status_code=422, detail={"code": "form.closed"})
@@ -622,7 +681,7 @@ async def submit_public_form(
volunteer.email,
subject,
body,
logo_url=edition_logo_url(session, edition.id),
logo=edition_logo(session, edition.id),
)
return FormSubmissionResult(volunteer_id=volunteer.id)
@@ -682,7 +741,7 @@ def _create_pending_checkout(
checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}",
description=f"{pending.payment_label} — édition {edition.year}",
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
redirect_url=f"{base}/inscription?paiement={pending.id}",
redirect_url=f"{base}/inscription/{template.access_token}?paiement={pending.id}",
)
if checkout is None:
return None
@@ -773,7 +832,7 @@ def _promote_pending(
volunteer.email,
subject,
body,
logo_url=edition_logo_url(session, edition.id),
logo=edition_logo(session, edition.id),
)
return volunteer
@@ -861,6 +920,7 @@ def get_pending_status(
`PublicSignupPage.tsx`) jusqu'à ce que le statut ne soit plus `PENDING` —
relit toujours l'état réel auprès de SumUp à chaque appel, jamais
seulement la dernière valeur connue en base."""
pending_status_limiter.hit(str(token))
pending = _get_pending_or_404(session, edition.id, token)
if pending.promoted_volunteer_id is None and pending.checkout_id:
template = _get_or_create_template(session, edition.id)