Étape 8 : connexion par mot de passe pour la coordination
Deux systèmes d'authentification, une même session JWT :
- COORDINATION : login e-mail + mot de passe (bcrypt), POST .../auth/login ;
POST .../auth/change-password pour le compte courant
- RÉFÉRENTS / BÉNÉVOLES : lien magique inchangé, mais refusé pour un compte
qui a un mot de passe
- Account.password_hash (nullable) + migration 0002 ; propriété auth_method
("password" / "magic_link") exposée dans AccountRead
- app/auth/passwords.py : hash/verify bcrypt, longueur mini 10
- Création de compte : password requis pour COORDINATOR, interdit sinon
- seed : la coordination de démo a un mot de passe, affiché dans le résumé
- 12 tests (login ok/ko, séparation des deux systèmes, change-password,
validation à la création) ; 129 tests au total ; alembic check vert
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1f9f460540
commit
8a879ca5c3
@@ -11,8 +11,9 @@ from app.api._common import owned_or_404
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import AccountCreate, AccountRead
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.auth.passwords import MIN_LENGTH, hash_password
|
||||
from app.db import get_session
|
||||
from app.models import Account, Edition, Volunteer
|
||||
from app.models import Account, Edition, Role, Volunteer
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/editions/{edition_id}/accounts", tags=["accounts"]
|
||||
@@ -32,11 +33,30 @@ def create_account(
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "account.volunteer_wrong_edition"}
|
||||
)
|
||||
|
||||
# Deux systèmes de connexion : mot de passe pour la COORDINATION,
|
||||
# lien magique pour les autres.
|
||||
if payload.role is Role.COORDINATOR:
|
||||
if not payload.password or len(payload.password) < MIN_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"code": "account.password_required", "min": MIN_LENGTH},
|
||||
)
|
||||
password_hash = hash_password(payload.password)
|
||||
else:
|
||||
if payload.password is not None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"code": "account.password_not_allowed"},
|
||||
)
|
||||
password_hash = None
|
||||
|
||||
account = Account(
|
||||
edition_id=edition.id,
|
||||
email=payload.email.strip(),
|
||||
role=payload.role,
|
||||
volunteer_id=payload.volunteer_id,
|
||||
password_hash=password_hash,
|
||||
)
|
||||
session.add(account)
|
||||
try:
|
||||
|
||||
+101
-22
@@ -1,4 +1,11 @@
|
||||
"""Endpoints d'authentification : lien magique, vérification, profil courant."""
|
||||
"""Authentification.
|
||||
|
||||
Deux systèmes, selon le compte :
|
||||
- **mot de passe** (`POST .../auth/login`) pour les comptes qui en ont un —
|
||||
la coordination ;
|
||||
- **lien magique** (`.../auth/magic-link` puis `.../auth/verify`) pour les
|
||||
comptes sans mot de passe — référents et bénévoles.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
@@ -10,11 +17,14 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.api.schemas import (
|
||||
AccountRead,
|
||||
ChangePasswordRequest,
|
||||
LoginRequest,
|
||||
MagicLinkRequest,
|
||||
TokenResponse,
|
||||
VerifyRequest,
|
||||
)
|
||||
from app.auth.deps import get_current_account
|
||||
from app.auth.passwords import MIN_LENGTH, hash_password, verify_password
|
||||
from app.auth.tokens import create_access_token, hash_token, new_raw_token
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
@@ -24,6 +34,89 @@ router = APIRouter(tags=["auth"])
|
||||
logger = logging.getLogger("app.auth")
|
||||
|
||||
|
||||
def _account_by_email(
|
||||
session: Session, edition_id: uuid.UUID, email: str
|
||||
) -> Account | None:
|
||||
return session.exec(
|
||||
select(Account)
|
||||
.where(Account.edition_id == edition_id)
|
||||
.where(func.lower(Account.email) == email.strip().lower())
|
||||
).first()
|
||||
|
||||
|
||||
def _issue(account: Account) -> TokenResponse:
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(account),
|
||||
token_type="bearer",
|
||||
account=AccountRead.model_validate(account),
|
||||
)
|
||||
|
||||
|
||||
def _invalid_credentials() -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"code": "auth.invalid_credentials"},
|
||||
)
|
||||
|
||||
|
||||
def _invalid_token() -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"code": "auth.invalid_token"},
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Mot de passe (admins) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@router.post("/editions/{edition_id}/auth/login", response_model=TokenResponse)
|
||||
def login(
|
||||
edition_id: uuid.UUID,
|
||||
payload: LoginRequest,
|
||||
session: Session = Depends(get_session),
|
||||
) -> TokenResponse:
|
||||
account = _account_by_email(session, edition_id, payload.email)
|
||||
if (
|
||||
account is None
|
||||
or account.password_hash is None
|
||||
or not verify_password(payload.password, account.password_hash)
|
||||
):
|
||||
raise _invalid_credentials()
|
||||
return _issue(account)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/editions/{edition_id}/auth/change-password",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def change_password(
|
||||
edition_id: uuid.UUID,
|
||||
payload: ChangePasswordRequest,
|
||||
account: Account = Depends(get_current_account),
|
||||
session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
if account.edition_id != edition_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "error.forbidden"},
|
||||
)
|
||||
if account.password_hash is None or not verify_password(
|
||||
payload.current_password, account.password_hash
|
||||
):
|
||||
raise _invalid_credentials()
|
||||
if len(payload.new_password) < MIN_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"code": "auth.password_too_short", "min": MIN_LENGTH},
|
||||
)
|
||||
account.password_hash = hash_password(payload.new_password)
|
||||
session.add(account)
|
||||
session.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Lien magique (référents, bénévoles) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@router.post(
|
||||
"/editions/{edition_id}/auth/magic-link",
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
@@ -33,14 +126,10 @@ def request_magic_link(
|
||||
payload: MagicLinkRequest,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
"""Émet un lien magique si un compte correspond. Réponse identique dans
|
||||
tous les cas (pas de divulgation de l'existence d'un compte)."""
|
||||
account = session.exec(
|
||||
select(Account)
|
||||
.where(Account.edition_id == edition_id)
|
||||
.where(func.lower(Account.email) == payload.email.strip().lower())
|
||||
).first()
|
||||
if account is not None:
|
||||
"""Émet un lien magique si un compte **sans mot de passe** correspond.
|
||||
Réponse identique dans tous les cas (pas de divulgation)."""
|
||||
account = _account_by_email(session, edition_id, payload.email)
|
||||
if account is not None and account.password_hash is None:
|
||||
raw = new_raw_token()
|
||||
token = MagicLinkToken(
|
||||
account_id=account.id,
|
||||
@@ -78,24 +167,14 @@ def verify_magic_link(
|
||||
or token.used_at is not None
|
||||
or token.expires_at < local_now()
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"code": "auth.invalid_token"},
|
||||
)
|
||||
raise _invalid_token()
|
||||
account = session.get(Account, token.account_id)
|
||||
if account is None or account.edition_id != edition_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"code": "auth.invalid_token"},
|
||||
)
|
||||
raise _invalid_token()
|
||||
token.used_at = local_now()
|
||||
session.commit()
|
||||
session.refresh(account)
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(account),
|
||||
token_type="bearer",
|
||||
account=AccountRead.model_validate(account),
|
||||
)
|
||||
return _issue(account)
|
||||
|
||||
|
||||
@router.get("/auth/me", response_model=AccountRead)
|
||||
|
||||
@@ -204,12 +204,24 @@ class VerifyRequest(ApiModel):
|
||||
token: str
|
||||
|
||||
|
||||
class LoginRequest(ApiModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(ApiModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class AccountRead(ApiModel):
|
||||
id: uuid.UUID
|
||||
edition_id: uuid.UUID
|
||||
email: str
|
||||
role: Role
|
||||
volunteer_id: uuid.UUID | None
|
||||
# Mode de connexion : "password" (admins) ou "magic_link".
|
||||
auth_method: str
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
@@ -224,3 +236,5 @@ class AccountCreate(ApiModel):
|
||||
email: str
|
||||
role: Role
|
||||
volunteer_id: uuid.UUID | None = None
|
||||
# Requis pour un compte COORDINATION, interdit sinon.
|
||||
password: str | None = None
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Hachage des mots de passe (connexion des admins)."""
|
||||
|
||||
import bcrypt
|
||||
|
||||
# bcrypt ignore au-delà de 72 octets : on tronque explicitement.
|
||||
_MAX_BYTES = 72
|
||||
MIN_LENGTH = 10
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
payload = plain.encode("utf-8")[:_MAX_BYTES]
|
||||
return bcrypt.hashpw(payload, bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(
|
||||
plain.encode("utf-8")[:_MAX_BYTES], hashed.encode("ascii")
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
@@ -11,8 +11,12 @@
|
||||
"assignment.wrong_edition": "Le bénévole ou le créneau n'appartient pas à cette édition.",
|
||||
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau.",
|
||||
"auth.invalid_token": "Lien de connexion invalide ou expiré.",
|
||||
"auth.invalid_credentials": "Identifiant ou mot de passe incorrect.",
|
||||
"auth.password_too_short": "Le mot de passe doit contenir au moins {min} caractères.",
|
||||
"account.duplicate": "Un compte existe déjà pour cet email ou ce bénévole.",
|
||||
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
|
||||
"account.password_required": "Un compte coordination requiert un mot de passe d'au moins {min} caractères.",
|
||||
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe.",
|
||||
|
||||
"import.error.empty_file": "Le fichier est vide.",
|
||||
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Compte : mot de passe optionnel (connexion admin)
|
||||
|
||||
Revision ID: 0002_account_password
|
||||
Revises: 0001_initial
|
||||
Create Date: 2026-09-10
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002_account_password"
|
||||
down_revision: str | None = "0001_initial"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"account",
|
||||
sa.Column("password_hash", sa.String(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("account", "password_hash")
|
||||
@@ -173,6 +173,9 @@ class Account(TimestampMixin, table=True):
|
||||
role: Role = Field(
|
||||
sa_column=Column(SAEnum(Role, native_enum=False), nullable=False)
|
||||
)
|
||||
# Défini ⇒ connexion par mot de passe (admins). None ⇒ connexion par lien
|
||||
# magique (référents, bénévoles).
|
||||
password_hash: str | None = None
|
||||
# Lien optionnel vers le bénévole. Supprimé avec lui (droit à l'effacement).
|
||||
volunteer_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
@@ -181,6 +184,10 @@ class Account(TimestampMixin, table=True):
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def auth_method(self) -> str:
|
||||
return "password" if self.password_hash else "magic_link"
|
||||
|
||||
|
||||
class MagicLinkToken(TimestampMixin, table=True):
|
||||
__tablename__ = "magic_link_token"
|
||||
|
||||
@@ -13,6 +13,7 @@ from dataclasses import dataclass, field
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth.passwords import hash_password
|
||||
from app.db import engine
|
||||
from app.importing.text import normalize
|
||||
from app.models import (
|
||||
@@ -47,6 +48,7 @@ from app.validation.loader import build_validation_input
|
||||
|
||||
SEED = 20260709
|
||||
DEMO_EDITION_NAME = "Festival des Lucioles"
|
||||
DEMO_COORDINATION_PASSWORD = "coordination-lucioles-2026"
|
||||
DEMO_YEAR = 2026
|
||||
START = dt.date(2026, 7, 9)
|
||||
END = dt.date(2026, 7, 12)
|
||||
@@ -229,6 +231,15 @@ class Summary:
|
||||
for code, count in sorted(self.warnings.items()):
|
||||
lines.append(f" {code} : {count}")
|
||||
lines.append(f" Violations bloquantes : {self.blocking}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
" Connexion coordination (mot de passe) : "
|
||||
"coordination@lucioles.test / " + DEMO_COORDINATION_PASSWORD
|
||||
)
|
||||
lines.append(
|
||||
" Référents et bénévoles : lien magique "
|
||||
"(referent0..2@lucioles.test, <prénom>.<nom><n>@example.test)"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -292,6 +303,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
edition_id=edition.id,
|
||||
email="coordination@lucioles.test",
|
||||
role=Role.COORDINATOR,
|
||||
password_hash=hash_password(DEMO_COORDINATION_PASSWORD),
|
||||
)
|
||||
leads = [
|
||||
Account(
|
||||
|
||||
Reference in New Issue
Block a user