É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:
AdamThomas
2026-09-09 23:42:31 +02:00
co-authored by Claude Sonnet 5
parent 1f9f460540
commit 8a879ca5c3
14 changed files with 446 additions and 41 deletions
+27 -13
View File
@@ -115,20 +115,33 @@ Matrice appliquée par les endpoints :
| `assignments` lecture | ✔ | ✔ | les siennes | | `assignments` lecture | ✔ | ✔ | les siennes |
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — | | `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
**Authentification** : lien magique + session JWT (`Bearer`), sans mot de passe. **Authentification — deux systèmes selon le compte**, une même session JWT
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée.
Le compte porte `password_hash` : défini ⇒ mot de passe, `NULL` ⇒ lien magique
(`Account.auth_method` = `"password"` / `"magic_link"`).
1. `POST /editions/{id}/auth/magic-link` `{email}` → réponse `202` identique *Mot de passe* — comptes `COORDINATOR` :
qu'un compte existe ou non. Si oui, un `MagicLinkToken` est créé (jeton
aléatoire, **haché** en base, TTL `magic_link_ttl_minutes`).
2. **Envoi d'email stubé** : le lien est écrit dans les logs (`app.auth`,
niveau INFO, `jeton=…` — identifiants seulement, aucune donnée nominative).
3. `POST /editions/{id}/auth/verify` `{token}` → JWT HS256
(`session_ttl_hours`) + `AccountRead`. Jeton à usage unique.
4. `GET /auth/me` → compte courant. En-tête `Authorization: Bearer <jwt>`.
Un bénévole sans email n'a pas de `Account` et ne se connecte pas ; il est géré 1. `POST /editions/{id}/auth/login` `{email, password}` → JWT + `AccountRead`.
entièrement par la coordination. La coordination crée les comptes via Échec = `401 auth.invalid_credentials` (aucune divulgation).
`POST /editions/{id}/accounts`. 2. `POST /editions/{id}/auth/change-password` `{current_password, new_password}`
(compte courant). Hachage **bcrypt**, longueur mini `MIN_LENGTH` (10).
*Lien magique* — comptes `LEAD` / `VOLUNTEER` (sans mot de passe) :
3. `POST /editions/{id}/auth/magic-link` `{email}` → `202` **constant**. Si un
compte **sans mot de passe** correspond, un `MagicLinkToken` est créé
(jeton aléatoire, **haché SHA-256** en base, TTL `magic_link_ttl_minutes`).
4. **Envoi d'email stubé** : le lien est écrit dans les logs (`app.auth`, INFO,
`jeton=…` — identifiants seulement, aucune donnée nominative).
5. `POST /editions/{id}/auth/verify` `{token}` → JWT + `AccountRead`. Jeton à
usage unique.
`GET /auth/me` → compte courant.
La coordination crée les comptes via `POST /editions/{id}/accounts` : un
`COORDINATOR` **exige** un `password` (≥ 10) ; un `LEAD` / `VOLUNTEER` le
**refuse** (lien magique). Un bénévole sans email n'a pas de `Account`.
--- ---
@@ -294,7 +307,8 @@ Aucune dépendance locale requise hors Docker.
| 4 | Moteur de validation des affectations (pur, TDD, 5 règles) | validée | | 4 | Moteur de validation des affectations (pur, TDD, 5 règles) | validée |
| 5 | API CRUD (Position, Shift, Volunteer, Assignment) + matrice + validation branchée | validée | | 5 | API CRUD (Position, Shift, Volunteer, Assignment) + matrice + validation branchée | validée |
| 6 | Jeu de données de démonstration (`app/seed.py`) | validée | | 6 | Jeu de données de démonstration (`app/seed.py`) | validée |
| 7 | Authentification : lien magique + JWT + application des rôles | à valider | | 7 | Authentification : lien magique + JWT + application des rôles | validée |
| 8 | Second système : connexion mot de passe (bcrypt) pour la coordination | à valider |
Premier incrément backend + authentification terminés. **Reste hors Premier incrément backend + authentification terminés. **Reste hors
périmètre** (modèle prêt à les accueillir) : envoi réel d'emails/SMS (le lien périmètre** (modèle prêt à les accueillir) : envoi réel d'emails/SMS (le lien
+21 -1
View File
@@ -11,8 +11,9 @@ from app.api._common import owned_or_404
from app.api.deps import get_edition from app.api.deps import get_edition
from app.api.schemas import AccountCreate, AccountRead from app.api.schemas import AccountCreate, AccountRead
from app.auth.deps import require_coordinator from app.auth.deps import require_coordinator
from app.auth.passwords import MIN_LENGTH, hash_password
from app.db import get_session from app.db import get_session
from app.models import Account, Edition, Volunteer from app.models import Account, Edition, Role, Volunteer
router = APIRouter( router = APIRouter(
prefix="/editions/{edition_id}/accounts", tags=["accounts"] prefix="/editions/{edition_id}/accounts", tags=["accounts"]
@@ -32,11 +33,30 @@ def create_account(
raise HTTPException( raise HTTPException(
status_code=422, detail={"code": "account.volunteer_wrong_edition"} 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( account = Account(
edition_id=edition.id, edition_id=edition.id,
email=payload.email.strip(), email=payload.email.strip(),
role=payload.role, role=payload.role,
volunteer_id=payload.volunteer_id, volunteer_id=payload.volunteer_id,
password_hash=password_hash,
) )
session.add(account) session.add(account)
try: try:
+101 -22
View File
@@ -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 datetime as dt
import logging import logging
@@ -10,11 +17,14 @@ from sqlmodel import Session, select
from app.api.schemas import ( from app.api.schemas import (
AccountRead, AccountRead,
ChangePasswordRequest,
LoginRequest,
MagicLinkRequest, MagicLinkRequest,
TokenResponse, TokenResponse,
VerifyRequest, VerifyRequest,
) )
from app.auth.deps import get_current_account 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.auth.tokens import create_access_token, hash_token, new_raw_token
from app.config import get_settings from app.config import get_settings
from app.db import get_session from app.db import get_session
@@ -24,6 +34,89 @@ router = APIRouter(tags=["auth"])
logger = logging.getLogger("app.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( @router.post(
"/editions/{edition_id}/auth/magic-link", "/editions/{edition_id}/auth/magic-link",
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
@@ -33,14 +126,10 @@ def request_magic_link(
payload: MagicLinkRequest, payload: MagicLinkRequest,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> dict[str, str]: ) -> dict[str, str]:
"""Émet un lien magique si un compte correspond. Réponse identique dans """Émet un lien magique si un compte **sans mot de passe** correspond.
tous les cas (pas de divulgation de l'existence d'un compte).""" Réponse identique dans tous les cas (pas de divulgation)."""
account = session.exec( account = _account_by_email(session, edition_id, payload.email)
select(Account) if account is not None and account.password_hash is None:
.where(Account.edition_id == edition_id)
.where(func.lower(Account.email) == payload.email.strip().lower())
).first()
if account is not None:
raw = new_raw_token() raw = new_raw_token()
token = MagicLinkToken( token = MagicLinkToken(
account_id=account.id, account_id=account.id,
@@ -78,24 +167,14 @@ def verify_magic_link(
or token.used_at is not None or token.used_at is not None
or token.expires_at < local_now() or token.expires_at < local_now()
): ):
raise HTTPException( raise _invalid_token()
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "auth.invalid_token"},
)
account = session.get(Account, token.account_id) account = session.get(Account, token.account_id)
if account is None or account.edition_id != edition_id: if account is None or account.edition_id != edition_id:
raise HTTPException( raise _invalid_token()
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "auth.invalid_token"},
)
token.used_at = local_now() token.used_at = local_now()
session.commit() session.commit()
session.refresh(account) session.refresh(account)
return TokenResponse( return _issue(account)
access_token=create_access_token(account),
token_type="bearer",
account=AccountRead.model_validate(account),
)
@router.get("/auth/me", response_model=AccountRead) @router.get("/auth/me", response_model=AccountRead)
+14
View File
@@ -204,12 +204,24 @@ class VerifyRequest(ApiModel):
token: str token: str
class LoginRequest(ApiModel):
email: str
password: str
class ChangePasswordRequest(ApiModel):
current_password: str
new_password: str
class AccountRead(ApiModel): class AccountRead(ApiModel):
id: uuid.UUID id: uuid.UUID
edition_id: uuid.UUID edition_id: uuid.UUID
email: str email: str
role: Role role: Role
volunteer_id: uuid.UUID | None volunteer_id: uuid.UUID | None
# Mode de connexion : "password" (admins) ou "magic_link".
auth_method: str
created_at: dt.datetime created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
@@ -224,3 +236,5 @@ class AccountCreate(ApiModel):
email: str email: str
role: Role role: Role
volunteer_id: uuid.UUID | None = None volunteer_id: uuid.UUID | None = None
# Requis pour un compte COORDINATION, interdit sinon.
password: str | None = None
+21
View File
@@ -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
+4
View File
@@ -11,8 +11,12 @@
"assignment.wrong_edition": "Le bénévole ou le créneau n'appartient pas à cette édition.", "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.", "assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau.",
"auth.invalid_token": "Lien de connexion invalide ou expiré.", "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.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.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.empty_file": "Le fichier est vide.",
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.", "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")
+7
View File
@@ -173,6 +173,9 @@ class Account(TimestampMixin, table=True):
role: Role = Field( role: Role = Field(
sa_column=Column(SAEnum(Role, native_enum=False), nullable=False) 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). # Lien optionnel vers le bénévole. Supprimé avec lui (droit à l'effacement).
volunteer_id: uuid.UUID | None = Field( volunteer_id: uuid.UUID | None = Field(
default=None, default=None,
@@ -181,6 +184,10 @@ class Account(TimestampMixin, table=True):
nullable=True, nullable=True,
) )
@property
def auth_method(self) -> str:
return "password" if self.password_hash else "magic_link"
class MagicLinkToken(TimestampMixin, table=True): class MagicLinkToken(TimestampMixin, table=True):
__tablename__ = "magic_link_token" __tablename__ = "magic_link_token"
+12
View File
@@ -13,6 +13,7 @@ from dataclasses import dataclass, field
from sqlmodel import Session, select from sqlmodel import Session, select
from app.auth.passwords import hash_password
from app.db import engine from app.db import engine
from app.importing.text import normalize from app.importing.text import normalize
from app.models import ( from app.models import (
@@ -47,6 +48,7 @@ from app.validation.loader import build_validation_input
SEED = 20260709 SEED = 20260709
DEMO_EDITION_NAME = "Festival des Lucioles" DEMO_EDITION_NAME = "Festival des Lucioles"
DEMO_COORDINATION_PASSWORD = "coordination-lucioles-2026"
DEMO_YEAR = 2026 DEMO_YEAR = 2026
START = dt.date(2026, 7, 9) START = dt.date(2026, 7, 9)
END = dt.date(2026, 7, 12) END = dt.date(2026, 7, 12)
@@ -229,6 +231,15 @@ class Summary:
for code, count in sorted(self.warnings.items()): for code, count in sorted(self.warnings.items()):
lines.append(f" {code} : {count}") lines.append(f" {code} : {count}")
lines.append(f" Violations bloquantes : {self.blocking}") 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) return "\n".join(lines)
@@ -292,6 +303,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
edition_id=edition.id, edition_id=edition.id,
email="coordination@lucioles.test", email="coordination@lucioles.test",
role=Role.COORDINATOR, role=Role.COORDINATOR,
password_hash=hash_password(DEMO_COORDINATION_PASSWORD),
) )
leads = [ leads = [
Account( Account(
+1
View File
@@ -13,6 +13,7 @@ dependencies = [
"python-multipart>=0.0.12", "python-multipart>=0.0.12",
"openpyxl>=3.1", "openpyxl>=3.1",
"pyjwt>=2.9", "pyjwt>=2.9",
"bcrypt>=4.1",
] ]
[dependency-groups] [dependency-groups]
+8 -1
View File
@@ -85,13 +85,20 @@ def make_account(session, edition):
counter = itertools.count() counter = itertools.count()
def _make( def _make(
role: Role, *, volunteer_id=None, email: str | None = None role: Role,
*,
volunteer_id=None,
email: str | None = None,
password: str | None = None,
) -> Account: ) -> Account:
from app.auth.passwords import hash_password
account = Account( account = Account(
edition_id=edition.id, edition_id=edition.id,
role=role, role=role,
email=email or f"{role.value.lower()}{next(counter)}@test", email=email or f"{role.value.lower()}{next(counter)}@test",
volunteer_id=volunteer_id, volunteer_id=volunteer_id,
password_hash=hash_password(password) if password else None,
) )
session.add(account) session.add(account)
session.commit() session.commit()
+5 -4
View File
@@ -24,11 +24,11 @@ def test_magic_link_unknown_email_is_accepted_without_token(
def test_full_flow_magic_link_then_jwt_then_me( def test_full_flow_magic_link_then_jwt_then_me(
raw_client, session, edition, make_account, caplog raw_client, session, edition, make_account, caplog
): ):
make_account(Role.COORDINATOR, email="coord@example.fr") make_account(Role.LEAD, email="ref@example.fr")
with caplog.at_level(logging.INFO, logger="app.auth"): with caplog.at_level(logging.INFO, logger="app.auth"):
resp = raw_client.post( resp = raw_client.post(
_url(edition, "magic-link"), json={"email": "COORD@example.fr"} _url(edition, "magic-link"), json={"email": "REF@example.fr"}
) )
assert resp.status_code == 202 assert resp.status_code == 202
log_line = next(m for m in caplog.messages if "STUB lien magique" in m) log_line = next(m for m in caplog.messages if "STUB lien magique" in m)
@@ -40,14 +40,15 @@ def test_full_flow_magic_link_then_jwt_then_me(
assert verified.status_code == 200 assert verified.status_code == 200
body = verified.json() body = verified.json()
assert body["token_type"] == "bearer" assert body["token_type"] == "bearer"
assert body["account"]["role"] == "COORDINATOR" assert body["account"]["role"] == "LEAD"
assert body["account"]["auth_method"] == "magic_link"
me = raw_client.get( me = raw_client.get(
"/auth/me", "/auth/me",
headers={"Authorization": f"Bearer {body['access_token']}"}, headers={"Authorization": f"Bearer {body['access_token']}"},
) )
assert me.status_code == 200 assert me.status_code == 200
assert me.json()["email"] == "coord@example.fr" assert me.json()["email"] == "ref@example.fr"
def test_verify_is_single_use(raw_client, session, edition, make_account): def test_verify_is_single_use(raw_client, session, edition, make_account):
+157
View File
@@ -0,0 +1,157 @@
"""Connexion par mot de passe (admins) et séparation des deux systèmes."""
from sqlmodel import select
from app.models import MagicLinkToken, Role
PASSWORD = "coordination-2026"
def _url(edition, suffix):
return f"/editions/{edition.id}/auth/{suffix}"
def test_login_with_correct_password(raw_client, edition, make_account):
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
resp = raw_client.post(
_url(edition, "login"),
json={"email": "ADMIN@e.fr", "password": PASSWORD},
)
assert resp.status_code == 200
body = resp.json()
assert body["token_type"] == "bearer"
assert body["account"]["role"] == "COORDINATOR"
assert body["account"]["auth_method"] == "password"
def test_login_rejects_wrong_password(raw_client, edition, make_account):
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
resp = raw_client.post(
_url(edition, "login"),
json={"email": "admin@e.fr", "password": "faux"},
)
assert resp.status_code == 401
assert resp.json()["detail"]["code"] == "auth.invalid_credentials"
def test_login_rejects_unknown_email(raw_client, edition):
resp = raw_client.post(
_url(edition, "login"),
json={"email": "personne@e.fr", "password": PASSWORD},
)
assert resp.status_code == 401
def test_login_fails_for_magic_link_account(raw_client, edition, make_account):
make_account(Role.LEAD, email="ref@e.fr") # sans mot de passe
resp = raw_client.post(
_url(edition, "login"),
json={"email": "ref@e.fr", "password": "peu importe"},
)
assert resp.status_code == 401
def test_magic_link_ignored_for_password_account(
raw_client, session, edition, make_account
):
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
resp = raw_client.post(
_url(edition, "magic-link"), json={"email": "admin@e.fr"}
)
assert resp.status_code == 202
assert session.exec(select(MagicLinkToken)).all() == []
def test_change_password(raw_client, edition, make_account):
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
jwt = raw_client.post(
_url(edition, "login"),
json={"email": "admin@e.fr", "password": PASSWORD},
).json()["access_token"]
headers = {"Authorization": f"Bearer {jwt}"}
changed = raw_client.post(
_url(edition, "change-password"),
json={"current_password": PASSWORD, "new_password": "nouveau-mdp-2026"},
headers=headers,
)
assert changed.status_code == 204
assert (
raw_client.post(
_url(edition, "login"),
json={"email": "admin@e.fr", "password": PASSWORD},
).status_code
== 401
)
assert (
raw_client.post(
_url(edition, "login"),
json={"email": "admin@e.fr", "password": "nouveau-mdp-2026"},
).status_code
== 200
)
def test_change_password_rejects_wrong_current(
raw_client, edition, make_account
):
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
jwt = raw_client.post(
_url(edition, "login"),
json={"email": "admin@e.fr", "password": PASSWORD},
).json()["access_token"]
resp = raw_client.post(
_url(edition, "change-password"),
json={"current_password": "faux", "new_password": "nouveau-mdp-2026"},
headers={"Authorization": f"Bearer {jwt}"},
)
assert resp.status_code == 401
# --------------------------------------------------------------------------- #
# Création de compte : mot de passe selon le rôle #
# --------------------------------------------------------------------------- #
def test_create_coordinator_requires_password(client, edition):
resp = client.post(
f"/editions/{edition.id}/accounts",
json={"email": "admin2@e.fr", "role": "COORDINATOR"},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "account.password_required"
def test_create_coordinator_with_password_can_log_in(
client, raw_client, edition
):
created = client.post(
f"/editions/{edition.id}/accounts",
json={
"email": "admin2@e.fr",
"role": "COORDINATOR",
"password": "un-mot-de-passe-solide",
},
)
assert created.status_code == 201
assert created.json()["auth_method"] == "password"
assert (
raw_client.post(
f"/editions/{edition.id}/auth/login",
json={"email": "admin2@e.fr", "password": "un-mot-de-passe-solide"},
).status_code
== 200
)
def test_create_lead_rejects_password(client, edition):
resp = client.post(
f"/editions/{edition.id}/accounts",
json={
"email": "ref2@e.fr",
"role": "LEAD",
"password": "pas-permis",
},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "account.password_not_allowed"
+40
View File
@@ -46,12 +46,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079 }, { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079 },
] ]
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553 },
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009 },
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029 },
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907 },
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500 },
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412 },
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486 },
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940 },
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776 },
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922 },
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367 },
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187 },
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752 },
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881 },
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931 },
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313 },
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290 },
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253 },
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084 },
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185 },
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656 },
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662 },
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240 },
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152 },
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284 },
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643 },
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698 },
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725 },
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912 },
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953 },
]
[[package]] [[package]]
name = "benevoles-backend" name = "benevoles-backend"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "bcrypt" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "openpyxl" }, { name = "openpyxl" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
@@ -73,6 +112,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.14" }, { name = "alembic", specifier = ">=1.14" },
{ name = "bcrypt", specifier = ">=4.1" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "openpyxl", specifier = ">=3.1" }, { name = "openpyxl", specifier = ">=3.1" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" },