Lien d'inscription protégé par jeton, reçus fiscaux mécénat, infobulles et revue de sécurité
- 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:
co-authored by
Claude Sonnet 5
parent
a2b748566b
commit
096b7cd26a
@@ -29,7 +29,7 @@ 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_url, render_magic_link, send
|
||||
from app.email import edition_logo, render_magic_link, send
|
||||
from app.models import Account, MagicLinkToken, local_now
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
@@ -144,7 +144,7 @@ def request_magic_link(
|
||||
minutes=settings.magic_link_ttl_minutes,
|
||||
)
|
||||
background.add_task(
|
||||
send, account.email, subject, body, logo_url=edition_logo_url(session, edition_id)
|
||||
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é.
|
||||
|
||||
+73
-13
@@ -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)
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.api.schemas import (
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
|
||||
from app.db import get_session
|
||||
from app.email import edition_logo_url, render_planning, send_many
|
||||
from app.email import edition_logo, render_planning, send_many
|
||||
from app.i18n import t
|
||||
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
|
||||
|
||||
@@ -112,7 +112,7 @@ def send_planning_notification(
|
||||
recipients.append(volunteer.id)
|
||||
|
||||
session.commit() # persiste les éventuels MagicLinkToken émis
|
||||
background.add_task(send_many, items, logo_url=edition_logo_url(session, edition.id))
|
||||
background.add_task(send_many, items, logo=edition_logo(session, edition.id))
|
||||
return PlanningNotificationResult(
|
||||
queued=len(items),
|
||||
skipped_no_email=skipped_no_email,
|
||||
|
||||
@@ -513,6 +513,10 @@ class FormTemplateRead(ApiModel):
|
||||
# `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est
|
||||
# défini, comme `has_logo` pour le logo.
|
||||
sumup_api_key_set: bool
|
||||
# Secret nécessaire pour atteindre le formulaire public
|
||||
# (`/inscription/<access_token>`, voir CLAUDE.md § 2, `FormTemplate`) —
|
||||
# exposé seulement ici (COORDINATION), jamais par `PublicFormConfig`.
|
||||
access_token: str
|
||||
|
||||
|
||||
class FormTemplateUpdate(ApiModel):
|
||||
|
||||
@@ -30,11 +30,13 @@ from app.auth.deps import (
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.exporting import generate_volunteers_xlsx
|
||||
from app.mecenat import MecenatReceipt, generate_mecenat_receipts_pdf
|
||||
from app.models import (
|
||||
Account,
|
||||
Artist,
|
||||
Assignment,
|
||||
Edition,
|
||||
FeePaymentStatus,
|
||||
Position,
|
||||
Role,
|
||||
Shift,
|
||||
@@ -220,6 +222,54 @@ def export_volunteers(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mecenat-receipts")
|
||||
def export_mecenat_receipts(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
"""Reçus fiscaux (cerfa 11580*05, `app/mecenat/pdf.py`) des bénévoles
|
||||
ayant payé un type de paiement « mécénat » — repérés par
|
||||
`mecenat_address` renseigné (voir CLAUDE.md § 2, Paiement de la
|
||||
cotisation : ce champ n'est rempli que pour ce chemin-là). Déclaré avant
|
||||
`/{volunteer_id}`, comme `/export` juste au-dessus."""
|
||||
volunteers = list(
|
||||
session.exec(
|
||||
select(Volunteer)
|
||||
.where(Volunteer.edition_id == edition.id)
|
||||
.where(Volunteer.fee_payment_status == FeePaymentStatus.PAID)
|
||||
.where(Volunteer.mecenat_address.is_not(None))
|
||||
.order_by(Volunteer.last_name, Volunteer.first_name)
|
||||
)
|
||||
)
|
||||
if not volunteers:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "mecenat.no_receipts"}
|
||||
)
|
||||
|
||||
receipts = [
|
||||
MecenatReceipt(
|
||||
last_name=v.last_name,
|
||||
first_name=v.first_name,
|
||||
address=v.mecenat_address or "",
|
||||
postal_code=v.mecenat_postal_code or "",
|
||||
city=v.mecenat_city or "",
|
||||
country=v.mecenat_country or "",
|
||||
amount=v.fee_amount or 0.0,
|
||||
payment_date=(v.fee_paid_at or v.created_at).date(),
|
||||
)
|
||||
for v in volunteers
|
||||
]
|
||||
content = generate_mecenat_receipts_pdf(receipts)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="recus-mecenat-{edition.year}.pdf"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{volunteer_id}", response_model=VolunteerRead)
|
||||
def get_volunteer(
|
||||
volunteer_id: uuid.UUID,
|
||||
|
||||
Reference in New Issue
Block a user