Lien de connexion directe permanent dans le récapitulatif de planning
{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:
co-authored by
Claude Opus 5.5
parent
8d6781e6ce
commit
05cdb0276b
@@ -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
|
||||
Reference in New Issue
Block a user