É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
+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)
|
||||
|
||||
Reference in New Issue
Block a user