{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>
65 lines
2.5 KiB
Python
65 lines
2.5 KiB
Python
"""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
|