É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
+8 -1
View File
@@ -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()
+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(
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):
+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"