É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
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user