Lien de connexion directe permanent dans le récapitulatif de planning
ci-cd / build-backend (push) Successful in 45s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 11s

{lien_connexion} devient un lien permanent (réutilisable, sans expiration,
signé HMAC, rien de stocké) au lieu d'un lien magique de 30 min à usage
unique ou d'un simple lien vers le site. Compte bénévole créé à l'envoi
s'il manque ; révocation par « Renouveler le lien de connexion » sur la
fiche (Account.login_link_version, migration 0031) ; jamais pour la
coordination.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 20:01:03 +01:00
co-authored by Claude Opus 5.5
parent 8d6781e6ce
commit 05cdb0276b
14 changed files with 316 additions and 16 deletions
+18
View File
@@ -109,6 +109,24 @@ def update_account(
return account
@router.post("/{account_id}/reset-login-link", response_model=AccountRead)
def reset_login_link(
account_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Account:
"""Révoque le lien de connexion permanent du compte (`app/auth/
permanent_link.py`) : tous les liens déjà envoyés cessent de
fonctionner ; le prochain récapitulatif portera un nouveau lien. Ex.
email transféré, téléphone perdu."""
account = owned_or_404(session.get(Account, account_id), edition.id)
account.login_link_version += 1
session.commit()
session.refresh(account)
return account
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_account(
account_id: uuid.UUID,
+12
View File
@@ -25,6 +25,8 @@ from app.api.schemas import (
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.permanent_link import PREFIX as PERMANENT_PREFIX
from app.auth.permanent_link import account_from_permanent_token
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
@@ -184,6 +186,16 @@ def verify_magic_link(
payload: VerifyRequest,
session: Session = Depends(get_session),
) -> TokenResponse:
raw = payload.token.strip()
if raw.startswith(PERMANENT_PREFIX):
# Lien permanent du récapitulatif de planning : réutilisable, sans
# expiration, révocable (voir `app/auth/permanent_link.py`).
account = account_from_permanent_token(session, raw)
if account is None or account.edition_id != edition_id:
logger.info("Lien permanent refusé (édition %s)", edition_id)
raise _invalid_token()
logger.info("Connexion par lien permanent : compte %s (édition %s)", account.id, edition_id)
return _issue(account)
token = session.exec(
select(MagicLinkToken).where(
MagicLinkToken.token_hash == hash_token(payload.token.strip())
+43 -9
View File
@@ -8,6 +8,7 @@ from dataclasses import dataclass
from email.message import EmailMessage
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import func
from sqlmodel import Session, select
from app.api.deps import get_edition
@@ -18,14 +19,15 @@ from app.api.schemas import (
TestEmailResult,
)
from app.auth.deps import require_coordinator
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
from app.auth.magic_link import build_magic_link_url
from app.auth.permanent_link import permanent_login_token
from app.config import get_settings
from app.db import get_session
from app.email import edition_logo, render_planning
from app.email.html import render_planning_table
from app.email.sender import build_message, deliver_test, send_messages
from app.i18n import t
from app.models import Account, Assignment, DayPart, Edition, Position, Shift, Volunteer
from app.models import Account, Assignment, DayPart, Edition, Position, Role, Shift, Volunteer
from app.validation.engine import day_boundary, festival_day
logger = logging.getLogger("app.notifications")
@@ -38,16 +40,48 @@ _LOGIN_PLACEHOLDER = "{lien_connexion}"
def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str:
"""Lien magique personnel si le bénévole a un compte, sinon l'accueil."""
"""Lien de connexion **permanent** du bénévole (`app/auth/
permanent_link.py` : connexion directe en un clic, réutilisable, sans
expiration, révocable). Envoyer ce lien vaut « donner un accès de
connexion » : un bénévole qui n'a pas encore de compte en reçoit un
(rôle `VOLUNTEER`, à son email). Si son email est déjà celui d'un autre
compte de l'édition (couple, famille partageant une adresse), pas de
compte possible : simple lien vers la page de connexion."""
account = session.exec(
select(Account)
.where(Account.edition_id == edition.id)
.where(Account.volunteer_id == volunteer.id)
.where(Account.password_hash.is_(None))
).first()
if account is None:
return build_magic_link_url("").split("?")[0]
return build_magic_link_url(issue_magic_link_token(session, account))
if account is None and volunteer.email:
email = volunteer.email.strip()
taken = session.exec(
select(Account.id)
.where(Account.edition_id == edition.id)
.where(func.lower(Account.email) == email.lower())
).first()
if taken is None:
account = Account(
edition_id=edition.id, email=email, role=Role.VOLUNTEER, volunteer_id=volunteer.id
)
session.add(account)
session.flush()
logger.info(
"Compte créé pour le lien de connexion : bénévole %s (compte %s)",
volunteer.id,
account.id,
)
if (
account is None
or account.password_hash is not None
or account.role not in (Role.VOLUNTEER, Role.LEAD)
):
return _login_page_url()
return build_magic_link_url(permanent_login_token(account))
def _login_page_url() -> str:
"""Page de connexion, sans jeton."""
return build_magic_link_url("").split("?")[0]
# Repère du tableau des créneaux dans la version HTML (caractère d'usage
@@ -222,7 +256,7 @@ def send_test_email(
first_name=example.first_name if example else t("email.test.example_first_name"),
last_name=example.last_name if example else t("email.test.example_last_name"),
shifts=shifts_by_volunteer.get(example.id, []) if example else [],
login_link=build_magic_link_url("").split("?")[0],
login_link=_login_page_url(),
)
message = build_message(
email,
@@ -300,7 +334,7 @@ def send_planning_notification(
)
recipients.append(volunteer.id)
session.commit() # persiste les éventuels MagicLinkToken émis
session.commit() # persiste les comptes créés pour les liens de connexion
logger.info(
"Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email",
edition.id,