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
+64
View File
@@ -0,0 +1,64 @@
"""Lien de connexion permanent — celui du récapitulatif de planning
(`{lien_connexion}`, `app/api/notifications.py`).
Contrairement au lien magique de la page de connexion (`magic_link.py` :
aléatoire, haché en base, 30 min, usage unique), ce lien se réutilise sans
limite de durée : le bénévole retrouve son planning en un clic depuis
n'importe quel email reçu. Il n'est stocké nulle part : c'est une signature
HMAC (clé `JWT_SECRET`) de l'identifiant du compte et de sa
`login_link_version`, donc le même lien figure dans tous les emails d'un
bénévole. Révocation : incrémenter la version (`POST .../accounts/{id}/
reset-login-link`) invalide tous les liens déjà envoyés à ce compte ;
changer `JWT_SECRET` les invalide tous. Bénévoles et référents seulement,
jamais la coordination ni un compte à mot de passe."""
import base64
import hashlib
import hmac
import uuid
from sqlmodel import Session
from app.config import get_settings
from app.models import Account, Role
# Jamais la coordination, même sans mot de passe : son accès complet ne
# doit pas tenir à un lien qui circule par email.
_ALLOWED_ROLES = (Role.VOLUNTEER, Role.LEAD)
PREFIX = "pl."
def _signature(account_id: uuid.UUID, version: int) -> str:
digest = hmac.new(
get_settings().jwt_secret.encode("utf-8"),
f"permanent-login:{account_id}:{version}".encode(),
hashlib.sha256,
).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def permanent_login_token(account: Account) -> str:
"""Jeton en clair du lien permanent d'un compte (toujours le même tant
que sa version ne change pas)."""
return f"{PREFIX}{account.id.hex}.{_signature(account.id, account.login_link_version)}"
def account_from_permanent_token(session: Session, token: str) -> Account | None:
"""Compte désigné par un jeton permanent valide, `None` sinon (format,
compte inconnu, signature ou version périmée, compte à mot de passe ou
de la coordination)."""
if not token.startswith(PREFIX):
return None
raw_id, _, signature = token[len(PREFIX) :].partition(".")
try:
account_id = uuid.UUID(hex=raw_id)
except ValueError:
return None
account = session.get(Account, account_id)
if account is None or account.password_hash is not None or account.role not in _ALLOWED_ROLES:
return None
expected = _signature(account.id, account.login_link_version)
if not hmac.compare_digest(signature, expected):
return None
return account