feat(backend): authentifie par mot de passe et refuse les routes par défaut

Connexion, lecture du compte connecté, RBAC à trois rôles ordonnés et
limitation de débit à fenêtre glissante. Ajoute `login_attempt`, le
compteur de la limitation, et `audit_log`, en ajout seul.

Trois ordres d'exécution portent la sécurité de ce commit, et chacun a
son test :

- les compteurs sont lus AVANT le hachage Argon2, sinon chaque requête
  rejetée coûterait quand même 17 ms et 19 Mio, et la protection serait
  l'amplificateur de déni de service qu'elle doit empêcher ;
- un haché leurre est vérifié quand l'adresse est inconnue, sinon l'écart
  entre 2 ms et 17 ms est un oracle d'existence de compte ;
- la tentative échouée est validée en base avant que l'erreur ne soit
  levée, `get_session()` ne validant pas de lui-même.

Pas de verrouillage de compte : il suffirait de cinq requêtes pour mettre
un administrateur dehors, et il ne fait rien contre le bourrage
d'identifiants horizontal. Trois seuils le remplacent, dont un par couple
(identifiant, IP) qui garantit qu'un attaquant ne peut pas empêcher la
victime de se connecter depuis sa propre adresse.

`audit_log` est en ajout seul au niveau de PostgreSQL, par deux
déclencheurs. Le second n'est pas redondant : TRUNCATE ne passe pas par
les déclencheurs de ligne.

`test_route_protection.py` interroge réellement chaque route sans jeton.
Rendre une route publique impose donc de modifier une liste dans un
fichier de test, ce qui se voit en revue.

Le gestionnaire de 422 arrive ici et non plus tard : la réponse par
défaut de FastAPI contient la valeur rejetée, donc le mot de passe. Le
test qui le prouve serait rouge sans lui.
This commit is contained in:
Johan LEROY
2026-09-15 14:41:25 +02:00
parent a8f59e6e76
commit ef933bea1a
24 changed files with 1904 additions and 10 deletions
@@ -0,0 +1,125 @@
# Les trois refus ci-dessous sont la preuve que l'ajout seul est une propriété de la base et
# non une convention de code Python. Ce sont eux qu'il faut montrer, pas la classe du dépôt.
import uuid
import pytest
from sqlalchemy import text
from sqlalchemy.exc import DBAPIError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.audit_log import AuditAction, AuditOutcome
from app.repositories.audit_log import (
CLES_DE_DETAIL_AUTORISEES,
AuditLogRepository,
assemble_detail,
)
pytestmark = pytest.mark.integration
ACTEUR = Principal(
id=uuid.uuid4(),
email="admin@enervision.fr",
role=Role.ADMIN,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
async def une_ligne(session: AsyncSession) -> None:
await AuditLogRepository(session).record(
action=AuditAction.COMPTE_CREE, actor=ACTEUR, target_type="app_user", target_id="x"
)
await session.flush()
@pytest.mark.parametrize(
"instruction",
[
"update audit_log set action = 'falsifie'",
"delete from audit_log",
"truncate audit_log",
],
ids=["modification", "suppression", "vidage"],
)
async def test_the_database_refuses_to_mutate_the_audit_log(
session: AsyncSession, instruction: str
) -> None:
await une_ligne(session)
with pytest.raises(DBAPIError, match="ajout seul"):
await session.execute(text(instruction))
await session.rollback()
async def test_record_keeps_a_snapshot_of_the_actor(session: AsyncSession) -> None:
depot = AuditLogRepository(session)
await depot.record(action=AuditAction.COMPTE_DESACTIVE, actor=ACTEUR)
await session.flush()
ligne = (
await session.execute(
text("select actor_id, actor_email, actor_role, outcome from audit_log")
)
).one()
await session.rollback()
assert ligne.actor_id == ACTEUR.id
assert ligne.actor_email == ACTEUR.email
assert ligne.actor_role == Role.ADMIN.value
assert ligne.outcome == AuditOutcome.SUCCES.value
async def test_record_accepts_a_label_when_there_is_no_authenticated_actor(
session: AsyncSession,
) -> None:
depot = AuditLogRepository(session)
await depot.record(action=AuditAction.ADMIN_AMORCE, actor_label="cli")
await session.flush()
ligne = (await session.execute(text("select actor_id, actor_email from audit_log"))).one()
await session.rollback()
assert ligne.actor_id is None
assert ligne.actor_email == "cli"
async def test_record_drops_the_detail_keys_outside_the_allow_list(
session: AsyncSession,
) -> None:
depot = AuditLogRepository(session)
await depot.record(
action=AuditAction.COMPTE_ROLE_CHANGE,
actor=ACTEUR,
detail={"role_avant": "lecteur", "mot_de_passe": "ne-doit-pas-passer"},
)
await session.flush()
detail = (await session.execute(text("select detail from audit_log"))).scalar_one()
await session.rollback()
assert detail == {"role_avant": "lecteur"}
@pytest.mark.parametrize(
("brut", "attendu"),
[
(None, {}),
({}, {}),
({"motif": "reutilisation"}, {"motif": "reutilisation"}),
({"password": "x"}, {}),
],
ids=["absent", "vide", "cle_autorisee", "cle_refusee"],
)
def test_assemble_detail_only_keeps_the_allowed_keys(
brut: dict[str, str] | None, attendu: dict[str, str]
) -> None:
assert assemble_detail(brut) == attendu
def test_the_allow_list_never_mentions_a_secret() -> None:
suspects = {"password", "mot_de_passe", "token", "jeton", "secret", "hash"}
assert CLES_DE_DETAIL_AUTORISEES & suspects == set()
@@ -0,0 +1,118 @@
import uuid
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.login_attempt import LoginOutcome
from app.repositories.login_attempt import LoginAttemptRepository
pytestmark = pytest.mark.integration
IP = "203.0.113.10"
AUTRE_IP = "198.51.100.7"
def adresse() -> str:
return f"tentative-{uuid.uuid4().hex[:12]}@enervision.fr"
async def echoue(
depot: LoginAttemptRepository, email: str, ip: str | None, combien: int = 1
) -> None:
for _ in range(combien):
await depot.record(email=email, client_ip=ip, outcome=LoginOutcome.IDENTIFIANTS_INVALIDES)
async def test_count_recent_failures_separates_the_three_counters(
session: AsyncSession,
) -> None:
depot = LoginAttemptRepository(session)
cible, voisin = adresse(), adresse()
await echoue(depot, cible, IP, combien=3)
await echoue(depot, cible, AUTRE_IP, combien=2)
await echoue(depot, voisin, IP, combien=4)
await session.flush()
compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900)
await session.rollback()
assert compteurs.per_identifier_and_ip == 3
assert compteurs.per_identifier == 5
assert compteurs.per_ip == 7
async def test_count_recent_failures_ignores_successful_attempts(
session: AsyncSession,
) -> None:
depot = LoginAttemptRepository(session)
cible = adresse()
await echoue(depot, cible, IP, combien=2)
await depot.record(email=cible, client_ip=IP, outcome=LoginOutcome.SUCCES)
await session.flush()
compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900)
await session.rollback()
assert compteurs.per_identifier_and_ip == 2
async def test_count_recent_failures_forgets_what_falls_outside_the_window(
session: AsyncSession,
) -> None:
depot = LoginAttemptRepository(session)
cible = adresse()
await echoue(depot, cible, IP, combien=2)
await session.flush()
await session.execute(
text(
"update login_attempt set occurred_at = now() - interval '2 hours' "
"where email_tried = :e"
),
{"e": cible},
)
compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900)
await session.rollback()
assert compteurs.per_identifier_and_ip == 0
async def test_count_recent_failures_still_counts_when_the_address_is_unknown(
session: AsyncSession,
) -> None:
depot = LoginAttemptRepository(session)
inconnu = adresse()
await echoue(depot, inconnu, IP, combien=5)
await session.flush()
compteurs = await depot.count_recent_failures(email=inconnu, client_ip=IP, window_seconds=900)
await session.rollback()
assert compteurs.per_identifier_and_ip == 5
async def test_record_normalises_the_address_before_counting(session: AsyncSession) -> None:
depot = LoginAttemptRepository(session)
cible = adresse()
await echoue(depot, cible.upper(), IP, combien=2)
await session.flush()
compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900)
await session.rollback()
assert compteurs.per_identifier_and_ip == 2
async def test_count_recent_failures_tolerates_a_missing_client_address(
session: AsyncSession,
) -> None:
depot = LoginAttemptRepository(session)
cible = adresse()
await echoue(depot, cible, None, combien=2)
await session.flush()
compteurs = await depot.count_recent_failures(email=cible, client_ip=None, window_seconds=900)
await session.rollback()
assert compteurs.per_identifier == 2
@@ -0,0 +1,196 @@
import uuid
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.roles import AccountKind, Role
from app.repositories.user import UserRepository
pytestmark = pytest.mark.integration
def adresse() -> str:
return f"compte-{uuid.uuid4().hex[:12]}@enervision.fr"
async def test_create_normalises_the_email_to_lower_case(session: AsyncSession) -> None:
depot = UserRepository(session)
saisie = adresse().upper()
compte = await depot.create(email=saisie, password_hash="$argon2id$x", role=Role.LECTEUR)
enregistre = compte.email
await session.rollback()
assert enregistre == saisie.lower()
async def test_the_database_refuses_an_email_written_in_upper_case(
session: AsyncSession,
) -> None:
saisie = adresse().upper()
with pytest.raises(IntegrityError):
await session.execute(
text(
"insert into app_user (email, password_hash, role) "
"values (:e, '$argon2id$x', 'lecteur')"
),
{"e": saisie},
)
await session.rollback()
async def test_the_database_refuses_two_accounts_sharing_an_email(
session: AsyncSession,
) -> None:
depot = UserRepository(session)
saisie = adresse()
await depot.create(email=saisie, password_hash="$argon2id$x", role=Role.LECTEUR)
with pytest.raises(IntegrityError):
await depot.create(email=saisie, password_hash="$argon2id$y", role=Role.ADMIN)
await session.rollback()
async def test_get_by_email_is_case_insensitive(session: AsyncSession) -> None:
depot = UserRepository(session)
saisie = adresse()
await depot.create(email=saisie, password_hash="$argon2id$x", role=Role.OPERATEUR)
trouve = await depot.get_by_email(saisie.upper())
role = trouve.role if trouve else None
await session.rollback()
assert role == Role.OPERATEUR.value
async def test_get_by_email_returns_nothing_for_an_unknown_address(
session: AsyncSession,
) -> None:
trouve = await UserRepository(session).get_by_email(adresse())
assert trouve is None
async def test_set_role_moves_the_credentials_marker_forward(session: AsyncSession) -> None:
depot = UserRepository(session)
compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR)
avant = compte.credentials_changed_at
await depot.set_role(compte.id, Role.ADMIN)
await session.refresh(compte)
apres, role = compte.credentials_changed_at, compte.role
await session.rollback()
assert role == Role.ADMIN.value
assert apres > avant
async def test_set_active_moves_the_credentials_marker_forward(session: AsyncSession) -> None:
depot = UserRepository(session)
compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR)
avant = compte.credentials_changed_at
await depot.set_active(compte.id, is_active=False)
await session.refresh(compte)
apres, actif = compte.credentials_changed_at, compte.is_active
await session.rollback()
assert actif is False
assert apres > avant
async def test_rehash_password_leaves_the_credentials_marker_untouched(
session: AsyncSession,
) -> None:
depot = UserRepository(session)
compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR)
avant = compte.credentials_changed_at
await depot.rehash_password(compte.id, "$argon2id$plus-recent")
await session.refresh(compte)
apres, empreinte = compte.credentials_changed_at, compte.password_hash
await session.rollback()
assert empreinte == "$argon2id$plus-recent"
assert apres == avant
async def test_update_password_moves_the_credentials_marker_forward(
session: AsyncSession,
) -> None:
depot = UserRepository(session)
compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR)
avant = compte.credentials_changed_at
await depot.update_password(compte.id, "$argon2id$neuf", must_change_password=False)
await session.refresh(compte)
apres = compte.credentials_changed_at
await session.rollback()
assert apres > avant
async def test_touch_last_login_records_the_connection_date(session: AsyncSession) -> None:
depot = UserRepository(session)
compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR)
await depot.touch_last_login(compte.id)
await session.refresh(compte)
date = compte.last_login_at
await session.rollback()
assert date is not None
async def test_count_active_admins_only_counts_enabled_administrators(
session: AsyncSession,
) -> None:
depot = UserRepository(session)
depart = await depot.count_active_admins()
await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.ADMIN)
desactive = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.ADMIN)
await depot.set_active(desactive.id, is_active=False)
total = await depot.count_active_admins()
await session.rollback()
assert total == depart + 1
async def test_create_accepts_a_service_account(session: AsyncSession) -> None:
depot = UserRepository(session)
compte = await depot.create(
email=adresse(),
password_hash="$argon2id$x",
role=Role.OPERATEUR,
kind=AccountKind.SERVICE,
)
nature = compte.kind
await session.rollback()
assert nature == AccountKind.SERVICE.value
async def test_list_all_returns_the_accounts_sorted_by_email(session: AsyncSession) -> None:
depot = UserRepository(session)
await depot.create(email=f"zz-{adresse()}", password_hash="$argon2id$x", role=Role.LECTEUR)
await depot.create(email=f"aa-{adresse()}", password_hash="$argon2id$x", role=Role.LECTEUR)
comptes = await depot.list_all()
emails = [compte.email for compte in comptes]
await session.rollback()
assert emails == sorted(emails)
async def test_get_by_id_returns_nothing_for_an_unknown_identifier(
session: AsyncSession,
) -> None:
trouve = await UserRepository(session).get_by_id(uuid.uuid4())
assert trouve is None