From 8a879ca5c3fe378dc76ea18b15e57c12c2975ba9 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Wed, 9 Sep 2026 23:42:31 +0200 Subject: [PATCH] =?UTF-8?q?=C3=89tape=208=20:=20connexion=20par=20mot=20de?= =?UTF-8?q?=20passe=20pour=20la=20coordination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM --- CLAUDE.md | 40 +++-- backend/app/api/accounts.py | 22 ++- backend/app/api/auth.py | 123 +++++++++++--- backend/app/api/schemas.py | 14 ++ backend/app/auth/passwords.py | 21 +++ backend/app/i18n/locales/fr.json | 4 + .../versions/0002_account_password_hash.py | 28 ++++ backend/app/models/domain.py | 7 + backend/app/seed.py | 12 ++ backend/pyproject.toml | 1 + backend/tests/conftest.py | 9 +- backend/tests/test_auth.py | 9 +- backend/tests/test_auth_password.py | 157 ++++++++++++++++++ backend/uv.lock | 40 +++++ 14 files changed, 446 insertions(+), 41 deletions(-) create mode 100644 backend/app/auth/passwords.py create mode 100644 backend/app/migrations/versions/0002_account_password_hash.py create mode 100644 backend/tests/test_auth_password.py diff --git a/CLAUDE.md b/CLAUDE.md index 538cdc3..c582a11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,20 +115,33 @@ Matrice appliquée par les endpoints : | `assignments` lecture | ✔ | ✔ | les siennes | | `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 `, 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 - 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 `. +*Mot de passe* — comptes `COORDINATOR` : -Un bénévole sans email n'a pas de `Account` et ne se connecte pas ; il est géré -entièrement par la coordination. La coordination crée les comptes via -`POST /editions/{id}/accounts`. +1. `POST /editions/{id}/auth/login` `{email, password}` → JWT + `AccountRead`. + Échec = `401 auth.invalid_credentials` (aucune divulgation). +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 | | 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 | -| 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 périmètre** (modèle prêt à les accueillir) : envoi réel d'emails/SMS (le lien diff --git a/backend/app/api/accounts.py b/backend/app/api/accounts.py index 3f26971..041ef03 100644 --- a/backend/app/api/accounts.py +++ b/backend/app/api/accounts.py @@ -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: diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 59e508b..b42b211 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 8bf92b5..96f1d08 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -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 diff --git a/backend/app/auth/passwords.py b/backend/app/auth/passwords.py new file mode 100644 index 0000000..799e98d --- /dev/null +++ b/backend/app/auth/passwords.py @@ -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 diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 21c3754..aaa6871 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -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.", diff --git a/backend/app/migrations/versions/0002_account_password_hash.py b/backend/app/migrations/versions/0002_account_password_hash.py new file mode 100644 index 0000000..ed6c516 --- /dev/null +++ b/backend/app/migrations/versions/0002_account_password_hash.py @@ -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") diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index e83eeca..cf21b75 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -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" diff --git a/backend/app/seed.py b/backend/app/seed.py index d329384..2c964de 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -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, .@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( diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5357e00..05107ad 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "python-multipart>=0.0.12", "openpyxl>=3.1", "pyjwt>=2.9", + "bcrypt>=4.1", ] [dependency-groups] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f9b0d28..039e25d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -85,13 +85,20 @@ def make_account(session, edition): counter = itertools.count() 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: + from app.auth.passwords import hash_password + account = Account( edition_id=edition.id, role=role, email=email or f"{role.value.lower()}{next(counter)}@test", volunteer_id=volunteer_id, + password_hash=hash_password(password) if password else None, ) session.add(account) session.commit() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 6b35795..d115936 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -24,11 +24,11 @@ def test_magic_link_unknown_email_is_accepted_without_token( def test_full_flow_magic_link_then_jwt_then_me( 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"): 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 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 body = verified.json() 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( "/auth/me", headers={"Authorization": f"Bearer {body['access_token']}"}, ) 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): diff --git a/backend/tests/test_auth_password.py b/backend/tests/test_auth_password.py new file mode 100644 index 0000000..2e7317f --- /dev/null +++ b/backend/tests/test_auth_password.py @@ -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" diff --git a/backend/uv.lock b/backend/uv.lock index 087747c..64e3a4d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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 }, ] +[[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]] name = "benevoles-backend" version = "0.1.0" source = { editable = "." } dependencies = [ { name = "alembic" }, + { name = "bcrypt" }, { name = "fastapi" }, { name = "openpyxl" }, { name = "psycopg", extra = ["binary"] }, @@ -73,6 +112,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.14" }, + { name = "bcrypt", specifier = ">=4.1" }, { name = "fastapi", specifier = ">=0.115" }, { name = "openpyxl", specifier = ">=3.1" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" },