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:
@@ -0,0 +1,106 @@
|
||||
from collections.abc import Iterator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.api.deps import get_auth_service
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
from app.services.auth import (
|
||||
AuthenticatedSession,
|
||||
InvalidCredentialsError,
|
||||
RateLimitedError,
|
||||
)
|
||||
|
||||
IDENTIFIANTS = {"email": "operateur@enervision.fr", "password": "un-mot-de-passe-valide"}
|
||||
|
||||
PRINCIPAL = Principal(
|
||||
id=uuid4(),
|
||||
email="operateur@enervision.fr",
|
||||
role=Role.OPERATEUR,
|
||||
kind=AccountKind.HUMAIN,
|
||||
must_change_password=False,
|
||||
)
|
||||
|
||||
|
||||
class FauxService:
|
||||
def __init__(self, erreur: Exception | None = None) -> None:
|
||||
self._erreur = erreur
|
||||
|
||||
async def authenticate(self, **_: object) -> AuthenticatedSession:
|
||||
if self._erreur is not None:
|
||||
raise self._erreur
|
||||
return AuthenticatedSession(
|
||||
principal=PRINCIPAL, access_token="un.jeton.factice", expires_in=900
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_auth_service(app: FastAPI) -> Iterator[list[Exception | None]]:
|
||||
programme: list[Exception | None] = [None]
|
||||
app.dependency_overrides[get_auth_service] = lambda: FauxService(programme[0])
|
||||
yield programme
|
||||
app.dependency_overrides.pop(get_auth_service, None)
|
||||
|
||||
|
||||
async def test_login_returns_the_token_and_the_principal_when_credentials_match(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS)
|
||||
|
||||
assert response.status_code == 200
|
||||
corps = response.json()
|
||||
assert corps["access_token"] == "un.jeton.factice"
|
||||
assert corps["token_type"] == "bearer"
|
||||
assert corps["principal"]["role"] == "operateur"
|
||||
|
||||
|
||||
async def test_login_forbids_intermediaries_from_caching_the_response(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS)
|
||||
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
async def test_login_never_reveals_which_half_of_the_credentials_was_wrong(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
fake_auth_service[0] = InvalidCredentialsError("Identifiants invalides")
|
||||
|
||||
response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Identifiants invalides"}
|
||||
|
||||
|
||||
async def test_login_returns_429_with_a_retry_after_when_the_rate_limit_is_reached(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
fake_auth_service[0] = RateLimitedError(900)
|
||||
|
||||
response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS)
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.headers["retry-after"] == "900"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"corps",
|
||||
[
|
||||
{"email": "pas-une-adresse", "password": "un-mot-de-passe-valide"},
|
||||
{"email": "operateur@enervision.fr"},
|
||||
{"email": "operateur@enervision.fr", "password": "x" * 129},
|
||||
],
|
||||
ids=["adresse_invalide", "mot_de_passe_absent", "mot_de_passe_trop_long"],
|
||||
)
|
||||
async def test_login_rejects_a_malformed_body_without_echoing_the_password(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient, corps: dict[str, str]
|
||||
) -> None:
|
||||
response = await client.post("/api/v1/auth/login", json=corps)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "un-mot-de-passe-valide" not in response.text
|
||||
assert "x" * 129 not in response.text
|
||||
@@ -0,0 +1,113 @@
|
||||
from collections.abc import Callable, Iterator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.api.deps import AdminDep, get_current_principal, require_role
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
|
||||
CHEMIN_ADMIN = "/api/v1/essai-admin"
|
||||
|
||||
|
||||
def principal(role: Role = Role.LECTEUR, *, must_change_password: bool = False) -> Principal:
|
||||
return Principal(
|
||||
id=uuid4(),
|
||||
email=f"{role.value}@enervision.fr",
|
||||
role=role,
|
||||
kind=AccountKind.HUMAIN,
|
||||
must_change_password=must_change_password,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def route_admin(app: FastAPI) -> None:
|
||||
@app.get(CHEMIN_ADMIN)
|
||||
async def _reserve_aux_admins(acteur: AdminDep) -> dict[str, str]:
|
||||
return {"email": acteur.email}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connecte(app: FastAPI) -> Iterator[Callable[[Principal], None]]:
|
||||
def installe(acteur: Principal) -> None:
|
||||
app.dependency_overrides[get_current_principal] = lambda: acteur
|
||||
|
||||
yield installe
|
||||
app.dependency_overrides.pop(get_current_principal, None)
|
||||
|
||||
|
||||
async def test_me_returns_401_when_no_credentials_are_sent(client: AsyncClient) -> None:
|
||||
response = await client.get("/api/v1/auth/me")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Bearer" in response.headers["www-authenticate"]
|
||||
|
||||
|
||||
async def test_me_returns_401_when_the_token_is_not_readable(client: AsyncClient) -> None:
|
||||
response = await client.get(
|
||||
"/api/v1/auth/me", headers={"Authorization": "Bearer nimporte.quoi.ici"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert 'error="invalid_token"' in response.headers["www-authenticate"]
|
||||
|
||||
|
||||
async def test_me_describes_the_connected_account(
|
||||
connecte: Callable[[Principal], None], client: AsyncClient
|
||||
) -> None:
|
||||
acteur = principal(Role.OPERATEUR)
|
||||
connecte(acteur)
|
||||
|
||||
response = await client.get("/api/v1/auth/me")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["email"] == acteur.email
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("role", "attendu"),
|
||||
[(Role.LECTEUR, 403), (Role.OPERATEUR, 403), (Role.ADMIN, 200)],
|
||||
ids=["lecteur_refuse", "operateur_refuse", "admin_accepte"],
|
||||
)
|
||||
async def test_an_admin_route_only_answers_to_an_admin(
|
||||
route_admin: None,
|
||||
connecte: Callable[[Principal], None],
|
||||
client: AsyncClient,
|
||||
role: Role,
|
||||
attendu: int,
|
||||
) -> None:
|
||||
connecte(principal(role))
|
||||
|
||||
response = await client.get(CHEMIN_ADMIN)
|
||||
|
||||
assert response.status_code == attendu
|
||||
|
||||
|
||||
async def test_a_pending_password_change_blocks_every_business_route(
|
||||
route_admin: None, connecte: Callable[[Principal], None], client: AsyncClient
|
||||
) -> None:
|
||||
connecte(principal(Role.ADMIN, must_change_password=True))
|
||||
|
||||
response = await client.get(CHEMIN_ADMIN)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "password_change_required"
|
||||
|
||||
|
||||
async def test_a_pending_password_change_still_allows_reading_ones_own_account(
|
||||
connecte: Callable[[Principal], None], client: AsyncClient
|
||||
) -> None:
|
||||
connecte(principal(Role.LECTEUR, must_change_password=True))
|
||||
|
||||
response = await client.get("/api/v1/auth/me")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["must_change_password"] is True
|
||||
|
||||
|
||||
def test_require_role_builds_one_guard_per_minimum_level() -> None:
|
||||
garde = require_role(Role.OPERATEUR)
|
||||
|
||||
assert callable(garde)
|
||||
@@ -0,0 +1,74 @@
|
||||
# Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier
|
||||
# `ROUTES_PUBLIQUES` ci-dessous, ce qui apparaît en clair dans la diff d'une pull request et
|
||||
# demande une justification au relecteur.
|
||||
# Pourquoi : il interroge réellement chaque route sans jeton au lieu d'inspecter l'arbre de
|
||||
# dépendances. L'arbre n'est accessible que par l'API privée de FastAPI, et surtout une route
|
||||
# peut porter la bonne dépendance tout en répondant quand même.
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient
|
||||
|
||||
ROUTES_PUBLIQUES = frozenset(
|
||||
{
|
||||
("GET", "/api/v1/health/live"),
|
||||
("GET", "/api/v1/health/ready"),
|
||||
("POST", "/api/v1/auth/login"),
|
||||
("GET", "/metrics"),
|
||||
}
|
||||
)
|
||||
|
||||
VALEURS_DE_SUBSTITUTION = "00000000-0000-0000-0000-000000000000"
|
||||
STATUTS_DE_REFUS = {401, 403}
|
||||
|
||||
|
||||
def routes_declarees(app: FastAPI) -> list[tuple[str, str]]:
|
||||
schema: dict[str, Any] = app.openapi()
|
||||
return [
|
||||
(methode.upper(), chemin)
|
||||
for chemin, operations in schema["paths"].items()
|
||||
for methode in operations
|
||||
if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"}
|
||||
]
|
||||
|
||||
|
||||
def routes_protegees(app: FastAPI) -> list[tuple[str, str]]:
|
||||
return [route for route in routes_declarees(app) if route not in ROUTES_PUBLIQUES]
|
||||
|
||||
|
||||
def test_the_public_allow_list_has_no_stale_entry(app: FastAPI) -> None:
|
||||
declarees = set(routes_declarees(app)) | {("GET", "/metrics")}
|
||||
|
||||
inconnues = ROUTES_PUBLIQUES - declarees
|
||||
|
||||
assert inconnues == set()
|
||||
|
||||
|
||||
async def test_every_route_rejects_an_anonymous_caller_unless_explicitly_public(
|
||||
app: FastAPI, client: AsyncClient
|
||||
) -> None:
|
||||
ouvertes: list[tuple[str, str, int]] = []
|
||||
|
||||
for methode, chemin in routes_protegees(app):
|
||||
concret = chemin.replace("{user_id}", VALEURS_DE_SUBSTITUTION)
|
||||
response = await client.request(methode, concret, json={})
|
||||
if response.status_code not in STATUTS_DE_REFUS:
|
||||
ouvertes.append((methode, chemin, response.status_code))
|
||||
|
||||
assert ouvertes == []
|
||||
|
||||
|
||||
async def test_the_declared_routes_are_actually_reachable(app: FastAPI) -> None:
|
||||
assert ("POST", "/api/v1/auth/login") in routes_declarees(app)
|
||||
assert ("GET", "/api/v1/auth/me") in routes_declarees(app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"chemin",
|
||||
["/api/v1/health/live", "/api/v1/health/ready"],
|
||||
ids=["sonde_de_vie", "sonde_de_disponibilite"],
|
||||
)
|
||||
def test_the_health_probes_stay_public(app: FastAPI, chemin: str) -> None:
|
||||
assert ("GET", chemin) in ROUTES_PUBLIQUES
|
||||
@@ -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
|
||||
@@ -0,0 +1,234 @@
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.security import TokenPolicy, decode_access_token
|
||||
from app.models.login_attempt import LoginOutcome
|
||||
from app.repositories.login_attempt import FailureCounts
|
||||
from app.services.auth import (
|
||||
AuthService,
|
||||
InvalidCredentialsError,
|
||||
LoginPolicy,
|
||||
RateLimitedError,
|
||||
)
|
||||
|
||||
POLITIQUE_JETON = TokenPolicy(
|
||||
secret="un-secret-de-test-de-plus-de-trente-deux-caracteres",
|
||||
issuer="enervision-api",
|
||||
audience="enervision-web",
|
||||
access_ttl=timedelta(minutes=15),
|
||||
)
|
||||
POLITIQUE_CONNEXION = LoginPolicy(
|
||||
window_seconds=900,
|
||||
max_failures_per_identifier_and_ip=5,
|
||||
max_failures_per_ip=20,
|
||||
max_failures_per_identifier=50,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FauxCompte:
|
||||
id: UUID = field(default_factory=uuid4)
|
||||
email: str = "operateur@enervision.fr"
|
||||
password_hash: str = "$argon2id$factice"
|
||||
role: str = "operateur"
|
||||
kind: str = "human"
|
||||
is_active: bool = True
|
||||
must_change_password: bool = False
|
||||
credentials_changed_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class FauxDepotComptes:
|
||||
def __init__(self, compte: FauxCompte | None) -> None:
|
||||
self.compte = compte
|
||||
self.rehachages = 0
|
||||
self.connexions_datees = 0
|
||||
|
||||
async def get_by_email(self, email: str) -> FauxCompte | None:
|
||||
return self.compte
|
||||
|
||||
async def rehash_password(self, user_id: UUID, password_hash: str) -> None:
|
||||
self.rehachages += 1
|
||||
|
||||
async def touch_last_login(self, user_id: UUID) -> None:
|
||||
self.connexions_datees += 1
|
||||
|
||||
|
||||
class FauxDepotTentatives:
|
||||
def __init__(self, compteurs: FailureCounts | None = None) -> None:
|
||||
self.compteurs = compteurs or FailureCounts(0, 0, 0)
|
||||
self.enregistrees: list[str] = []
|
||||
|
||||
async def count_recent_failures(self, **_: object) -> FailureCounts:
|
||||
return self.compteurs
|
||||
|
||||
async def record(self, *, outcome: object, **_: object) -> None:
|
||||
self.enregistrees.append(str(outcome))
|
||||
|
||||
|
||||
class FauxDepotAudit:
|
||||
def __init__(self) -> None:
|
||||
self.lignes: list[tuple[str, Mapping[str, Any] | None]] = []
|
||||
|
||||
async def record(self, *, action: object, detail: Any = None, **_: object) -> None:
|
||||
self.lignes.append((str(action), detail))
|
||||
|
||||
|
||||
class FauxHacheur:
|
||||
def __init__(self, *, accepte: bool = True, rehachage_requis: bool = False) -> None:
|
||||
self.verifications = 0
|
||||
self.hachages = 0
|
||||
self._accepte = accepte
|
||||
self._rehachage_requis = rehachage_requis
|
||||
|
||||
async def hash(self, password: str) -> str:
|
||||
self.hachages += 1
|
||||
return "$argon2id$nouvelle"
|
||||
|
||||
async def verify(self, stored: str, password: str) -> bool:
|
||||
self.verifications += 1
|
||||
return self._accepte
|
||||
|
||||
async def verify_dummy(self) -> None:
|
||||
self.verifications += 1
|
||||
|
||||
def needs_rehash(self, stored: str) -> bool:
|
||||
return self._rehachage_requis
|
||||
|
||||
|
||||
class FausseTransaction:
|
||||
def __init__(self) -> None:
|
||||
self.validations = 0
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.validations += 1
|
||||
|
||||
|
||||
def fabrique_service(
|
||||
*,
|
||||
compte: FauxCompte | None = None,
|
||||
compteurs: FailureCounts | None = None,
|
||||
hacheur: FauxHacheur | None = None,
|
||||
) -> tuple[AuthService, FauxDepotComptes, FauxDepotTentatives, FauxDepotAudit, FauxHacheur]:
|
||||
comptes = FauxDepotComptes(compte)
|
||||
tentatives = FauxDepotTentatives(compteurs)
|
||||
audit = FauxDepotAudit()
|
||||
hacheur = hacheur or FauxHacheur()
|
||||
service = AuthService(
|
||||
users=comptes, # type: ignore[arg-type]
|
||||
attempts=tentatives, # type: ignore[arg-type]
|
||||
audit=audit, # type: ignore[arg-type]
|
||||
hasher=hacheur, # type: ignore[arg-type]
|
||||
transaction=FausseTransaction(),
|
||||
token_policy=POLITIQUE_JETON,
|
||||
login_policy=POLITIQUE_CONNEXION,
|
||||
)
|
||||
return service, comptes, tentatives, audit, hacheur
|
||||
|
||||
|
||||
async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object:
|
||||
return await service.authenticate(
|
||||
email="operateur@enervision.fr",
|
||||
password=mot_de_passe,
|
||||
client_ip="203.0.113.10",
|
||||
user_agent="pytest",
|
||||
)
|
||||
|
||||
|
||||
async def test_authenticate_returns_a_readable_access_token_when_credentials_match() -> None:
|
||||
compte = FauxCompte()
|
||||
service, comptes, tentatives, _, _ = fabrique_service(compte=compte)
|
||||
|
||||
session = await connecte(service)
|
||||
|
||||
claims = decode_access_token(POLITIQUE_JETON, session.access_token) # type: ignore[attr-defined]
|
||||
assert claims.subject == compte.id
|
||||
assert claims.role == "operateur"
|
||||
assert tentatives.enregistrees == [LoginOutcome.SUCCES.value]
|
||||
assert comptes.connexions_datees == 1
|
||||
|
||||
|
||||
async def test_authenticate_verifies_a_decoy_digest_when_the_email_is_unknown() -> None:
|
||||
service, _, tentatives, _, hacheur = fabrique_service(compte=None)
|
||||
|
||||
with pytest.raises(InvalidCredentialsError):
|
||||
await connecte(service)
|
||||
|
||||
assert hacheur.verifications == 1
|
||||
assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value]
|
||||
|
||||
|
||||
async def test_authenticate_skips_hashing_entirely_when_the_rate_limit_is_reached() -> None:
|
||||
compteurs = FailureCounts(per_identifier_and_ip=5, per_ip=5, per_identifier=5)
|
||||
service, _, tentatives, audit, hacheur = fabrique_service(
|
||||
compte=FauxCompte(), compteurs=compteurs
|
||||
)
|
||||
|
||||
with pytest.raises(RateLimitedError):
|
||||
await connecte(service)
|
||||
|
||||
assert hacheur.verifications == 0
|
||||
assert hacheur.hachages == 0
|
||||
assert tentatives.enregistrees == [LoginOutcome.LIMITE.value]
|
||||
assert audit.lignes == []
|
||||
|
||||
|
||||
async def test_authenticate_audits_when_the_identifier_threshold_alone_is_reached() -> None:
|
||||
compteurs = FailureCounts(per_identifier_and_ip=0, per_ip=0, per_identifier=50)
|
||||
service, _, _, audit, _ = fabrique_service(compte=FauxCompte(), compteurs=compteurs)
|
||||
|
||||
with pytest.raises(RateLimitedError):
|
||||
await connecte(service)
|
||||
|
||||
assert len(audit.lignes) == 1
|
||||
assert "identifier_throttled" in audit.lignes[0][0]
|
||||
|
||||
|
||||
async def test_authenticate_rejects_a_wrong_password_with_the_generic_error() -> None:
|
||||
service, _, tentatives, _, _ = fabrique_service(
|
||||
compte=FauxCompte(), hacheur=FauxHacheur(accepte=False)
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidCredentialsError):
|
||||
await connecte(service)
|
||||
|
||||
assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"compte",
|
||||
[FauxCompte(is_active=False), FauxCompte(kind="service")],
|
||||
ids=["compte_desactive", "compte_de_service"],
|
||||
)
|
||||
async def test_authenticate_rejects_unavailable_accounts_after_checking_the_password(
|
||||
compte: FauxCompte,
|
||||
) -> None:
|
||||
service, _, tentatives, _, hacheur = fabrique_service(compte=compte)
|
||||
|
||||
with pytest.raises(InvalidCredentialsError):
|
||||
await connecte(service)
|
||||
|
||||
assert hacheur.verifications == 1
|
||||
assert tentatives.enregistrees == [LoginOutcome.COMPTE_INDISPONIBLE.value]
|
||||
|
||||
|
||||
async def test_authenticate_rehashes_the_password_when_the_parameters_changed() -> None:
|
||||
service, comptes, _, _, _ = fabrique_service(
|
||||
compte=FauxCompte(), hacheur=FauxHacheur(rehachage_requis=True)
|
||||
)
|
||||
|
||||
await connecte(service)
|
||||
|
||||
assert comptes.rehachages == 1
|
||||
|
||||
|
||||
async def test_authenticate_leaves_the_digest_alone_when_the_parameters_match() -> None:
|
||||
service, comptes, _, _, _ = fabrique_service(compte=FauxCompte())
|
||||
|
||||
await connecte(service)
|
||||
|
||||
assert comptes.rehachages == 0
|
||||
@@ -0,0 +1,57 @@
|
||||
import pytest
|
||||
|
||||
from app import cli
|
||||
|
||||
|
||||
def test_build_parser_reads_the_create_admin_arguments() -> None:
|
||||
arguments = cli.build_parser().parse_args(
|
||||
["create-admin", "--email", "admin@enervision.fr", "--generate", "--force"]
|
||||
)
|
||||
|
||||
assert arguments.commande == "create-admin"
|
||||
assert arguments.email == "admin@enervision.fr"
|
||||
assert arguments.generate is True
|
||||
assert arguments.force is True
|
||||
|
||||
|
||||
def test_build_parser_requires_a_subcommand() -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
cli.build_parser().parse_args([])
|
||||
|
||||
|
||||
def test_build_parser_requires_an_email() -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
cli.build_parser().parse_args(["create-admin"])
|
||||
|
||||
|
||||
def test_read_password_generates_a_long_secret_when_asked(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
mot_de_passe = cli.read_password(generate=True)
|
||||
|
||||
assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE
|
||||
assert mot_de_passe in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
saisies = iter(["un-mot-de-passe-valide", "un-mot-de-passe-valide"])
|
||||
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
|
||||
|
||||
assert cli.read_password(generate=False) == "un-mot-de-passe-valide"
|
||||
|
||||
|
||||
def test_read_password_refuses_a_password_below_the_minimum_length(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(cli, "getpass", lambda _: "court")
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli.read_password(generate=False)
|
||||
|
||||
|
||||
def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
saisies = iter(["un-mot-de-passe-valide", "un-autre-mot-de-passe"])
|
||||
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli.read_password(generate=False)
|
||||
Reference in New Issue
Block a user