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
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user