"""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