From e4d1b43a44d88ba5bed1fb9e930e4ca5854aad7e Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:21:46 +0200 Subject: [PATCH 01/13] =?UTF-8?q?style(backend):=20r=C3=A9tablit=20les=20a?= =?UTF-8?q?ccents=20dans=20les=20messages=20et=20commentaires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le français du dépôt s'écrit accentué. Harmonise les commentaires d'en-tête, les docstrings, le message de démarrage et les deux détails d'erreur de la sonde de disponibilité, avec leurs assertions. --- apps/backend/app/api/v1/endpoints/health.py | 4 ++-- apps/backend/app/db/base.py | 2 +- apps/backend/app/main.py | 2 +- apps/backend/app/models/__init__.py | 4 ++-- apps/backend/tests/api/test_health.py | 2 +- apps/backend/tests/conftest.py | 10 +++++----- apps/backend/tests/factories.py | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index be3abf8..f97caaf 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -27,10 +27,10 @@ async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) except SQLAlchemyError, OSError: - logger.exception("Base de donnees injoignable") + logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Base de donnees injoignable", + detail="Base de données injoignable", ) from None if version is None: diff --git a/apps/backend/app/db/base.py b/apps/backend/app/db/base.py index a1a552c..1830f3e 100644 --- a/apps/backend/app/db/base.py +++ b/apps/backend/app/db/base.py @@ -2,4 +2,4 @@ from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): - """Base declarative commune a tous les modeles.""" + """Base déclarative commune à tous les modèles.""" diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index fa717f5..1008100 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -17,7 +17,7 @@ logger = get_logger(__name__) async def lifespan(_: FastAPI) -> AsyncIterator[None]: settings = get_settings() logger.info( - "Demarrage de %s %s en environnement %s", settings.name, settings.version, settings.env + "Démarrage de %s %s en environnement %s", settings.name, settings.version, settings.env ) yield await get_engine().dispose() diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 6d71227..2ac405d 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,2 +1,2 @@ -# Piege : tout modele absent de ce module reste invisible de `alembic revision -# --autogenerate`, qui genererait alors un drop de sa table. +# Piège : tout modèle absent de ce module reste invisible de `alembic revision +# --autogenerate`, qui générerait alors un drop de sa table. diff --git a/apps/backend/tests/api/test_health.py b/apps/backend/tests/api/test_health.py index a7a61b6..d5ba9bc 100644 --- a/apps/backend/tests/api/test_health.py +++ b/apps/backend/tests/api/test_health.py @@ -59,7 +59,7 @@ async def test_readiness_returns_503_when_database_is_unreachable( response = await client.get("/api/v1/health/ready") assert response.status_code == 503 - assert response.json()["detail"] == "Base de donnees injoignable" + assert response.json()["detail"] == "Base de données injoignable" @pytest.mark.parametrize("path", ["/openapi.json", "/metrics"]) diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index 4560b75..70ba87a 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -12,8 +12,8 @@ from app.main import create_app from tests.factories import FakeSession -# Piege : les variables d'environnement priment sur apps/backend/.env. Celles qu'on ne -# pose pas ici, c'est le .env du poste qui les decide, et les assertions avec. +# Piège : les variables d'environnement priment sur apps/backend/.env. Celles qu'on ne +# pose pas ici, c'est le .env du poste qui les décide, et les assertions avec. @pytest.fixture(autouse=True, scope="session") def environment() -> Iterator[None]: os.environ.update( @@ -33,8 +33,8 @@ def environment() -> Iterator[None]: get_settings.cache_clear() -# Piege : get_engine est lru_cache et pytest-asyncio ouvre une boucle par test. Sans ce -# recyclage, le 2e test touchant vraiment la base heriterait d une boucle morte. +# Piège : get_engine est lru_cache et pytest-asyncio ouvre une boucle par test. Sans ce +# recyclage, le 2e test touchant vraiment la base hériterait d'une boucle morte. @pytest.fixture(autouse=True) async def engine_per_test() -> AsyncIterator[None]: yield @@ -67,7 +67,7 @@ def fake_session(app: FastAPI) -> Callable[..., None]: return install -# Contrainte : ouvre une vraie connexion, donc reservee aux tests `integration`. +# Contrainte : ouvre une vraie connexion, donc réservée aux tests `integration`. @pytest.fixture async def session() -> AsyncIterator[AsyncSession]: async with get_session_factory()() as async_session: diff --git a/apps/backend/tests/factories.py b/apps/backend/tests/factories.py index 05ba3fc..3098863 100644 --- a/apps/backend/tests/factories.py +++ b/apps/backend/tests/factories.py @@ -31,7 +31,7 @@ class FakeSession: return self._result -# Piege : les arguments nommes priment sur l'environnement et sur .env, contrairement -# aux variables posees par la fixture `environment`, qui restent surchargeables. +# Piège : les arguments nommés priment sur l'environnement et sur .env, contrairement +# aux variables posées par la fixture `environment`, qui restent surchargeables. def make_settings(**overrides: Any) -> Settings: return Settings(**{**SETTINGS_DE_TEST, **overrides}) From 008cf581a7c0c131c1ef10fffc343e03326cfe27 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:22:09 +0200 Subject: [PATCH 02/13] =?UTF-8?q?ci(backend):=20v=C3=A9rifie=20format,=20l?= =?UTF-8?q?int,=20typage=20et=20tests=20=C3=A0=20chaque=20pouss=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.github/workflows/` ne contenait qu'un `.gitkeep` alors que l'EC03 évalue la CI en continu. Périmètre volontairement minimal, aligné sur `make check` : le scan de sécurité et la construction d'image relèvent du chantier CI/CD et viendront l'étendre. --- .github/workflows/backend.yml | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/backend.yml diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..b146eb5 --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,58 @@ +name: Backend + +# Piège : la version de Python vient de apps/backend/.python-version, et elle doit rester +# en 3.14. Le code utilise le PEP 758, qu'un interpréteur 3.13 refuse de compiler. + +on: + push: + paths: + - "apps/backend/**" + - ".github/workflows/backend.yml" + pull_request: + paths: + - "apps/backend/**" + - ".github/workflows/backend.yml" + +permissions: + contents: read + +concurrency: + group: backend-${{ github.ref }} + cancel-in-progress: true + +jobs: + verification: + name: Lint, typage et tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/backend + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + + - name: Vérifie le formatage + run: uv run ruff format --check . + + - name: Analyse statique + run: uv run ruff check --output-format=github . + + - name: Typage + run: uv run mypy app + + # Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici. + - name: Tests et couverture + run: uv run pytest --cov-fail-under=85 From 53af7a76d8ac16d21ed2ef97554fc5fbc649ff65 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:25:41 +0200 Subject: [PATCH 03/13] =?UTF-8?q?feat(backend):=20pose=20les=20primitives?= =?UTF-8?q?=20de=20s=C3=A9curit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Couche pure, sans FastAPI ni session : rôles ordonnés, `Principal`, encodage et décodage des jetons d'accès, empreinte des jetons de rafraîchissement, et hachage Argon2id poussé dans un fil borné. Aucun de ces modules ne lit `get_settings()`, mis en cache par `lru_cache` et donc contaminé entre tests : les paramètres arrivent par `TokenPolicy` et par `build_hasher()`. Argon2id est calibré à m=19456 KiB, t=2, p=1, soit 17 ms mesurés sur un poste de développement. --- apps/backend/app/core/hashing.py | 63 ++++++++ apps/backend/app/core/principal.py | 18 +++ apps/backend/app/core/roles.py | 26 +++ apps/backend/app/core/security.py | 117 ++++++++++++++ apps/backend/pyproject.toml | 6 +- apps/backend/tests/core/test_hashing.py | 61 +++++++ apps/backend/tests/core/test_roles.py | 40 +++++ apps/backend/tests/core/test_security.py | 194 +++++++++++++++++++++++ apps/backend/uv.lock | 103 ++++++++++++ 9 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 apps/backend/app/core/hashing.py create mode 100644 apps/backend/app/core/principal.py create mode 100644 apps/backend/app/core/roles.py create mode 100644 apps/backend/app/core/security.py create mode 100644 apps/backend/tests/core/test_hashing.py create mode 100644 apps/backend/tests/core/test_roles.py create mode 100644 apps/backend/tests/core/test_security.py diff --git a/apps/backend/app/core/hashing.py b/apps/backend/app/core/hashing.py new file mode 100644 index 0000000..0cbc975 --- /dev/null +++ b/apps/backend/app/core/hashing.py @@ -0,0 +1,63 @@ +# Piège : `PasswordHasher.verify()` bloque 17 ms. Appelé tel quel dans un `async def`, il fige +# la boucle d'événements et gèle toutes les requêtes en cours, pas seulement la connexion. +# `Argon2Hasher` le pousse donc dans un fil, sous un `CapacityLimiter` : le pool par défaut +# d'anyio accepte 40 fils, soit 40 x 19 Mio dans le pire cas sur une machine qui héberge aussi +# PostgreSQL, Prometheus et Grafana. +# Piège : `verify_dummy()` doit être appelé quand l'utilisateur est introuvable. Sans lui, +# l'écart entre 2 ms et 17 ms est un oracle d'existence de compte, mesurable à distance. + +import secrets + +import anyio +import anyio.to_thread +from argon2 import PasswordHasher +from argon2.exceptions import Argon2Error, InvalidHashError, VerificationError + +_ERREURS_DE_VERIFICATION = (VerificationError, InvalidHashError, Argon2Error) + + +class Argon2Hasher: + def __init__(self, hasher: PasswordHasher, *, max_concurrency: int) -> None: + self._hasher = hasher + self._limiter = anyio.CapacityLimiter(max_concurrency) + self._leurre = hasher.hash(secrets.token_urlsafe(32)) + + async def hash(self, password: str) -> str: + return await anyio.to_thread.run_sync(self._hasher.hash, password, limiter=self._limiter) + + async def verify(self, stored: str, password: str) -> bool: + return await anyio.to_thread.run_sync(self._verify, stored, password, limiter=self._limiter) + + async def verify_dummy(self) -> None: + await self.verify(self._leurre, "") + + def needs_rehash(self, stored: str) -> bool: + try: + return self._hasher.check_needs_rehash(stored) + except _ERREURS_DE_VERIFICATION: + return True + + def _verify(self, stored: str, password: str) -> bool: + try: + return self._hasher.verify(stored, password) + except _ERREURS_DE_VERIFICATION: + return False + + +def build_hasher( + *, + time_cost: int, + memory_cost_kib: int, + parallelism: int, + max_concurrency: int, +) -> Argon2Hasher: + return Argon2Hasher( + PasswordHasher( + time_cost=time_cost, + memory_cost=memory_cost_kib, + parallelism=parallelism, + hash_len=32, + salt_len=16, + ), + max_concurrency=max_concurrency, + ) diff --git a/apps/backend/app/core/principal.py b/apps/backend/app/core/principal.py new file mode 100644 index 0000000..af69bdc --- /dev/null +++ b/apps/backend/app/core/principal.py @@ -0,0 +1,18 @@ +# Pourquoi : tout le code métier dépend de `Principal` et jamais du modèle ORM ni des claims +# du jeton. C'est ce qui garde la bascule vers un fournisseur OIDC locale à +# `get_current_principal()` et à `AuthService.authenticate()`, au lieu de la répandre dans +# chaque endpoint. + +from dataclasses import dataclass +from uuid import UUID + +from app.core.roles import AccountKind, Role + + +@dataclass(frozen=True, slots=True) +class Principal: + id: UUID + email: str + role: Role + kind: AccountKind + must_change_password: bool diff --git a/apps/backend/app/core/roles.py b/apps/backend/app/core/roles.py new file mode 100644 index 0000000..211b187 --- /dev/null +++ b/apps/backend/app/core/roles.py @@ -0,0 +1,26 @@ +from enum import StrEnum +from typing import Final + + +class Role(StrEnum): + # Contrainte : ces valeurs voyagent en base, en JSON et dans les jetons. Elles restent + # en ASCII, contrairement au libellé « opérateur » affiché à l'utilisateur. + LECTEUR = "lecteur" + OPERATEUR = "operateur" + ADMIN = "admin" + + +class AccountKind(StrEnum): + HUMAIN = "human" + SERVICE = "service" + + +ROLE_RANK: Final[dict[Role, int]] = { + Role.LECTEUR: 0, + Role.OPERATEUR: 1, + Role.ADMIN: 2, +} + + +def has_at_least(actual: Role, required: Role) -> bool: + return ROLE_RANK[actual] >= ROLE_RANK[required] diff --git a/apps/backend/app/core/security.py b/apps/backend/app/core/security.py new file mode 100644 index 0000000..a9b71e5 --- /dev/null +++ b/apps/backend/app/core/security.py @@ -0,0 +1,117 @@ +# Piège : `decode_access_token()` porte trois barrières indépendantes, et retirer l'une +# d'elles ne casse aucun test évident. L'algorithme est épinglé, sinon un jeton forgé en +# `alg: none` passerait. L'audience et l'émetteur sont vérifiés, sinon un jeton émis pour +# un autre service serait accepté. Le claim `typ` est comparé, sinon un jeton de +# rafraîchissement servirait de jeton d'accès, ce qui transformerait une fenêtre de +# 15 minutes en fenêtre de 7 jours. +# Contrainte : ce module ne lit jamais `get_settings()`, qui est mis en cache par +# `lru_cache` et se contaminerait entre tests. Tout paramètre arrive par `TokenPolicy`. + +import hashlib +import secrets +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Final +from uuid import UUID, uuid4 + +import jwt + +ACCESS_TOKEN_TYPE: Final = "access" # noqa: S105 +REFRESH_SECRET_BYTES: Final = 32 + +_ALGORITHME: Final = "HS256" +_CLAIMS_REQUIS: Final = ["iss", "aud", "sub", "iat", "exp", "jti", "typ", "role", "kind"] + + +class TokenInvalidError(Exception): + pass + + +class TokenExpiredError(TokenInvalidError): + pass + + +@dataclass(frozen=True, slots=True) +class TokenPolicy: + secret: str + issuer: str + audience: str + access_ttl: timedelta + + +@dataclass(frozen=True, slots=True) +class AccessClaims: + subject: UUID + role: str + kind: str + token_id: UUID + issued_at: datetime + + +def encode_access_token( + policy: TokenPolicy, + *, + subject: UUID, + role: str, + kind: str, + now: datetime | None = None, +) -> str: + emis_a = now or datetime.now(UTC) + return jwt.encode( + { + "iss": policy.issuer, + "aud": policy.audience, + "sub": str(subject), + "iat": emis_a, + "exp": emis_a + policy.access_ttl, + "jti": str(uuid4()), + "typ": ACCESS_TOKEN_TYPE, + "role": role, + "kind": kind, + }, + policy.secret, + algorithm=_ALGORITHME, + ) + + +def decode_access_token(policy: TokenPolicy, token: str) -> AccessClaims: + try: + charge = jwt.decode( + token, + policy.secret, + algorithms=[_ALGORITHME], + audience=policy.audience, + issuer=policy.issuer, + options={"require": _CLAIMS_REQUIS}, + ) + except jwt.ExpiredSignatureError as erreur: + raise TokenExpiredError("Jeton expiré") from erreur + except jwt.InvalidTokenError as erreur: + raise TokenInvalidError("Jeton invalide") from erreur + + if charge["typ"] != ACCESS_TOKEN_TYPE: + raise TokenInvalidError("Type de jeton inattendu") + + try: + sujet = UUID(charge["sub"]) + identifiant = UUID(charge["jti"]) + except (AttributeError, TypeError, ValueError) as erreur: + raise TokenInvalidError("Identifiants du jeton illisibles") from erreur + + return AccessClaims( + subject=sujet, + role=str(charge["role"]), + kind=str(charge["kind"]), + token_id=identifiant, + issued_at=datetime.fromtimestamp(charge["iat"], tz=UTC), + ) + + +def generate_refresh_secret() -> str: + return secrets.token_urlsafe(REFRESH_SECRET_BYTES) + + +# SHA-256 nu, pas Argon2id : 256 bits de CSPRNG n'ont ni dictionnaire ni préimage atteignable, +# et une KDF lente coûterait 17 ms à chaque rafraîchissement pour aucun gain. +def fingerprint_refresh(secret: str) -> bytes: + return hashlib.sha256(secret.encode("utf-8")).digest() diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 1c27c08..684524d 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -13,6 +13,9 @@ dependencies = [ "alembic>=1.20.0", "prometheus-fastapi-instrumentator>=8.1.0", "python-json-logger>=4.2.0", + "pyjwt>=2.10", + "argon2-cffi>=23.1", + "anyio>=4.0", ] [dependency-groups] @@ -57,7 +60,8 @@ select = [ ignore = ["B008"] [tool.ruff.lint.per-file-ignores] -"tests/**/*.py" = ["S101"] +# S105 et S106 signalent les secrets en dur, ce qui est justement la matière des tests d'auth. +"tests/**/*.py" = ["S101", "S105", "S106"] [tool.ruff.lint.isort] known-first-party = ["app"] diff --git a/apps/backend/tests/core/test_hashing.py b/apps/backend/tests/core/test_hashing.py new file mode 100644 index 0000000..c56c713 --- /dev/null +++ b/apps/backend/tests/core/test_hashing.py @@ -0,0 +1,61 @@ +from app.core.hashing import Argon2Hasher, build_hasher + +MOT_DE_PASSE = "un-mot-de-passe-de-test-assez-long" + + +def fabrique(time_cost: int = 1, max_concurrency: int = 2) -> Argon2Hasher: + return build_hasher( + time_cost=time_cost, + memory_cost_kib=8192, + parallelism=1, + max_concurrency=max_concurrency, + ) + + +async def test_hash_produces_a_distinct_digest_for_the_same_password() -> None: + hacheur = fabrique() + + premier = await hacheur.hash(MOT_DE_PASSE) + second = await hacheur.hash(MOT_DE_PASSE) + + assert premier != second + assert premier.startswith("$argon2id$") + + +async def test_verify_accepts_the_right_password_and_rejects_the_others() -> None: + hacheur = fabrique() + + empreinte = await hacheur.hash(MOT_DE_PASSE) + + assert await hacheur.verify(empreinte, MOT_DE_PASSE) is True + assert await hacheur.verify(empreinte, "un-autre-mot-de-passe") is False + + +async def test_verify_returns_false_when_the_stored_digest_is_malformed() -> None: + hacheur = fabrique() + + accorde = await hacheur.verify("pas-une-empreinte-argon2", MOT_DE_PASSE) + + assert accorde is False + + +async def test_needs_rehash_is_true_when_the_parameters_changed() -> None: + ancien = fabrique(time_cost=1) + recent = fabrique(time_cost=3) + + empreinte = await ancien.hash(MOT_DE_PASSE) + + assert ancien.needs_rehash(empreinte) is False + assert recent.needs_rehash(empreinte) is True + + +def test_needs_rehash_is_true_when_the_stored_digest_is_malformed() -> None: + hacheur = fabrique() + + assert hacheur.needs_rehash("pas-une-empreinte-argon2") is True + + +async def test_verify_dummy_completes_without_revealing_anything() -> None: + hacheur = fabrique() + + await hacheur.verify_dummy() diff --git a/apps/backend/tests/core/test_roles.py b/apps/backend/tests/core/test_roles.py new file mode 100644 index 0000000..fdcafe7 --- /dev/null +++ b/apps/backend/tests/core/test_roles.py @@ -0,0 +1,40 @@ +import pytest + +from app.core.roles import Role, has_at_least + + +@pytest.mark.parametrize( + ("actual", "required", "expected"), + [ + (Role.LECTEUR, Role.LECTEUR, True), + (Role.LECTEUR, Role.OPERATEUR, False), + (Role.LECTEUR, Role.ADMIN, False), + (Role.OPERATEUR, Role.LECTEUR, True), + (Role.OPERATEUR, Role.OPERATEUR, True), + (Role.OPERATEUR, Role.ADMIN, False), + (Role.ADMIN, Role.LECTEUR, True), + (Role.ADMIN, Role.OPERATEUR, True), + (Role.ADMIN, Role.ADMIN, True), + ], + ids=[ + "lecteur_sur_lecteur", + "lecteur_sur_operateur", + "lecteur_sur_admin", + "operateur_sur_lecteur", + "operateur_sur_operateur", + "operateur_sur_admin", + "admin_sur_lecteur", + "admin_sur_operateur", + "admin_sur_admin", + ], +) +def test_has_at_least_orders_the_three_roles(actual: Role, required: Role, expected: bool) -> None: + accorde = has_at_least(actual, required) + + assert accorde is expected + + +def test_role_values_stay_ascii_for_the_wire_format() -> None: + valeurs = [role.value for role in Role] + + assert all(valeur.isascii() for valeur in valeurs) diff --git a/apps/backend/tests/core/test_security.py b/apps/backend/tests/core/test_security.py new file mode 100644 index 0000000..88a8611 --- /dev/null +++ b/apps/backend/tests/core/test_security.py @@ -0,0 +1,194 @@ +import base64 +import json +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import jwt +import pytest + +from app.core.security import ( + AccessClaims, + TokenExpiredError, + TokenInvalidError, + TokenPolicy, + decode_access_token, + encode_access_token, + fingerprint_refresh, + generate_refresh_secret, +) + +POLITIQUE = TokenPolicy( + secret="un-secret-de-test-de-plus-de-trente-deux-caracteres", + issuer="enervision-api", + audience="enervision-web", + access_ttl=timedelta(minutes=15), +) + + +def emets(**surcharges: object) -> str: + charge = { + "iss": POLITIQUE.issuer, + "aud": POLITIQUE.audience, + "sub": str(uuid4()), + "iat": datetime.now(UTC), + "exp": datetime.now(UTC) + timedelta(minutes=15), + "jti": str(uuid4()), + "typ": "access", + "role": "lecteur", + "kind": "human", + } + charge.update(surcharges) + return jwt.encode(charge, POLITIQUE.secret, algorithm="HS256") + + +def test_decode_access_token_returns_the_claims_when_the_token_is_valid() -> None: + sujet = uuid4() + + jeton = encode_access_token(POLITIQUE, subject=sujet, role="operateur", kind="human") + claims = decode_access_token(POLITIQUE, jeton) + + assert isinstance(claims, AccessClaims) + assert claims.subject == sujet + assert claims.role == "operateur" + assert claims.kind == "human" + + +def test_decode_access_token_raises_expired_when_the_lifetime_has_passed() -> None: + passe = datetime.now(UTC) - timedelta(hours=2) + + jeton = encode_access_token(POLITIQUE, subject=uuid4(), role="lecteur", kind="human", now=passe) + + with pytest.raises(TokenExpiredError): + decode_access_token(POLITIQUE, jeton) + + +def test_decode_access_token_raises_invalid_when_the_signature_was_forged() -> None: + autre = TokenPolicy( + secret="un-autre-secret-tout-aussi-long-que-le-premier", + issuer=POLITIQUE.issuer, + audience=POLITIQUE.audience, + access_ttl=POLITIQUE.access_ttl, + ) + + jeton = encode_access_token(autre, subject=uuid4(), role="lecteur", kind="human") + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +@pytest.mark.parametrize( + "surcharges", + [ + {"aud": "un-autre-public"}, + {"iss": "un-autre-emetteur"}, + {"typ": "refresh"}, + ], + ids=["audience_invalide", "emetteur_invalide", "jeton_de_rafraichissement"], +) +def test_decode_access_token_raises_invalid_when_a_claim_is_wrong( + surcharges: dict[str, object], +) -> None: + jeton = emets(**surcharges) + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +@pytest.mark.parametrize( + "claim", + ["jti", "typ", "role", "kind"], + ids=["identifiant", "type", "role", "nature_du_compte"], +) +def test_decode_access_token_raises_invalid_when_a_required_claim_is_missing(claim: str) -> None: + charge = { + "iss": POLITIQUE.issuer, + "aud": POLITIQUE.audience, + "sub": str(uuid4()), + "iat": datetime.now(UTC), + "exp": datetime.now(UTC) + timedelta(minutes=15), + "jti": str(uuid4()), + "typ": "access", + "role": "lecteur", + "kind": "human", + } + del charge[claim] + + jeton = jwt.encode(charge, POLITIQUE.secret, algorithm="HS256") + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +def test_decode_access_token_rejects_a_token_forged_with_the_none_algorithm() -> None: + def encode(donnees: dict[str, object]) -> str: + brut = json.dumps(donnees, separators=(",", ":")).encode() + return base64.urlsafe_b64encode(brut).rstrip(b"=").decode() + + entete = encode({"alg": "none", "typ": "JWT"}) + charge = encode( + { + "iss": POLITIQUE.issuer, + "aud": POLITIQUE.audience, + "sub": str(uuid4()), + "iat": int(datetime.now(UTC).timestamp()), + "exp": int((datetime.now(UTC) + timedelta(minutes=15)).timestamp()), + "jti": str(uuid4()), + "typ": "access", + "role": "admin", + "kind": "human", + } + ) + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, f"{entete}.{charge}.") + + +def test_decode_access_token_rejects_a_token_signed_with_another_algorithm() -> None: + charge = { + "iss": POLITIQUE.issuer, + "aud": POLITIQUE.audience, + "sub": str(uuid4()), + "iat": datetime.now(UTC), + "exp": datetime.now(UTC) + timedelta(minutes=15), + "jti": str(uuid4()), + "typ": "access", + "role": "admin", + "kind": "human", + } + + jeton = jwt.encode(charge, POLITIQUE.secret * 2, algorithm="HS512") + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +@pytest.mark.parametrize( + "surcharges", + [{"sub": "pas-un-uuid"}, {"jti": "pas-un-uuid"}], + ids=["sujet_illisible", "identifiant_illisible"], +) +def test_decode_access_token_raises_invalid_when_an_identifier_is_not_a_uuid( + surcharges: dict[str, object], +) -> None: + jeton = emets(**surcharges) + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +def test_generate_refresh_secret_returns_distinct_url_safe_values() -> None: + secrets_generes = {generate_refresh_secret() for _ in range(100)} + + assert len(secrets_generes) == 100 + assert all(len(valeur) >= 43 for valeur in secrets_generes) + + +def test_fingerprint_refresh_is_stable_and_distinguishes_two_secrets() -> None: + premier = generate_refresh_secret() + second = generate_refresh_secret() + + empreinte = fingerprint_refresh(premier) + + assert len(empreinte) == 32 + assert empreinte == fingerprint_refresh(premier) + assert empreinte != fingerprint_refresh(second) diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index f799110..edc09c2 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -47,6 +47,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" }, + { url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e", size = 15449, upload-time = "2026-08-20T07:32:54.075Z" }, + { url = "https://files.pythonhosted.org/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31", size = 25683, upload-time = "2026-08-20T07:32:55.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f", size = 27311, upload-time = "2026-08-20T07:32:56.166Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98", size = 26771, upload-time = "2026-08-20T07:32:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605", size = 27568, upload-time = "2026-08-20T07:32:58.361Z" }, + { url = "https://files.pythonhosted.org/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2", size = 27280, upload-time = "2026-08-20T07:32:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a", size = 27776, upload-time = "2026-08-20T07:33:00.325Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a", size = 26932, upload-time = "2026-08-20T07:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35", size = 24878, upload-time = "2026-08-20T07:33:02.189Z" }, + { url = "https://files.pythonhosted.org/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8", size = 26656, upload-time = "2026-08-20T07:33:03.222Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" }, +] + [[package]] name = "ast-serialize" version = "0.11.2" @@ -143,6 +187,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -206,11 +285,14 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "alembic" }, + { name = "anyio" }, + { name = "argon2-cffi" }, { name = "asyncpg" }, { name = "fastapi" }, { name = "prometheus-fastapi-instrumentator" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt" }, { name = "python-json-logger" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "uvicorn", extra = ["standard"] }, @@ -229,11 +311,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.20.0" }, + { name = "anyio", specifier = ">=4.0" }, + { name = "argon2-cffi", specifier = ">=23.1" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "fastapi", specifier = ">=0.141.1" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" }, { name = "pydantic", specifier = ">=2.13.5" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, + { name = "pyjwt", specifier = ">=2.10" }, { name = "python-json-logger", specifier = ">=4.2.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.52" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.53.0" }, @@ -537,6 +622,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/b9/91a2246e6cf01b7ccb14479803c8a50f9c258ae5c6a0f16ba3294820632b/prometheus_fastapi_instrumentator-8.1.0-py3-none-any.whl", hash = "sha256:b9f40b2cff3f7891ca0610b3ae4fc6ec723fd326b04bb659819aaeb821a0fc7d", size = 19649, upload-time = "2026-07-26T11:12:45.168Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.5" @@ -616,6 +710,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyjwt" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, +] + [[package]] name = "pytest" version = "9.1.1" From a8f59e6e76fca98de7ab3946c130cdc6591f4ade Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:30:05 +0200 Subject: [PATCH 04/13] =?UTF-8?q?feat(backend):=20ajoute=20les=20comptes?= =?UTF-8?q?=20applicatifs=20et=20l'amor=C3=A7age=20du=20premier=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table `app_user`, son dépôt, et la commande `create-admin`. Le nom évite `user`, mot réservé de PostgreSQL, et rappelle qu'il s'agit d'un compte applicatif, par opposition au rôle PostgreSQL qui portera le cantonnement des accès ETL et ML. `credentials_changed_at` couvre à elle seule le changement de mot de passe, le changement de rôle et la désactivation : tout jeton émis avant cet instant sera refusé, sans attendre son expiration. La configuration refuse désormais de démarrer sur cinq erreurs silencieuses : secret trop court ou laissé à sa valeur d'exemple, `debug` en production, joker CORS, origines vides hors local, et cookie `SameSite=None` sans `Secure`. Les fixtures de test et les deux `.env.example` suivent, sans quoi rien ne démarrerait. Le mot de passe de l'admin ne transite jamais par `argv`, visible de tout `ps` : il est saisi par `getpass` ou tiré au sort. Une révision Alembic qui insérerait ce compte graverait son empreinte dans Git pour toujours. --- .env.example | 12 +- Makefile | 25 +++-- apps/backend/.env.example | 6 +- .../b1a7c3d9e240_comptes_applicatifs.py | 72 ++++++++++++ apps/backend/app/cli.py | 104 ++++++++++++++++++ apps/backend/app/core/config.py | 72 +++++++++++- apps/backend/app/core/cookies.py | 54 +++++++++ apps/backend/app/models/__init__.py | 4 + apps/backend/app/models/user.py | 50 +++++++++ apps/backend/app/repositories/user.py | 97 ++++++++++++++++ apps/backend/tests/conftest.py | 2 +- apps/backend/tests/core/test_config.py | 78 +++++++++++++ apps/backend/tests/core/test_cookies.py | 58 ++++++++++ apps/backend/tests/factories.py | 2 +- 14 files changed, 615 insertions(+), 21 deletions(-) create mode 100644 apps/backend/alembic/versions/b1a7c3d9e240_comptes_applicatifs.py create mode 100644 apps/backend/app/cli.py create mode 100644 apps/backend/app/core/cookies.py create mode 100644 apps/backend/app/models/user.py create mode 100644 apps/backend/app/repositories/user.py create mode 100644 apps/backend/tests/core/test_config.py create mode 100644 apps/backend/tests/core/test_cookies.py diff --git a/.env.example b/.env.example index a5fba5a..54dc3d8 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,19 @@ -# Variables lues par docker-compose.yml a la racine. -# Le backend lance hors conteneur (`make dev`) lit apps/backend/.env, pas ce fichier. +# Variables lues par docker-compose.yml à la racine. +# Le backend lancé hors conteneur (`make dev`) lit apps/backend/.env, pas ce fichier. POSTGRES_USER=enervision POSTGRES_PASSWORD=change_me POSTGRES_DB=enervision -# 5432 est souvent deja pris par une autre base du poste. +# 5432 est souvent déjà pris par une autre base du poste. POSTGRES_PORT=5433 -# `basic` renvoie des statistiques d'usage a Timescale. +# `basic` renvoie des statistiques d'usage à Timescale. TIMESCALEDB_TELEMETRY=off APP_ENV=local -APP_DEBUG=true +APP_DEBUG=false APP_LOG_LEVEL=INFO +# L'API refuse de démarrer tant que cette valeur reste un exemple ou fait moins de +# 32 caractères. Générer la vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))" APP_SECRET_KEY=change_me APP_CORS_ORIGINS=http://localhost:4200 BACKEND_PORT=8000 diff --git a/Makefile b/Makefile index 81c3e6d..bf45b61 100644 --- a/Makefile +++ b/Makefile @@ -2,15 +2,15 @@ BACKEND := apps/backend .DEFAULT_GOAL := help .PHONY: help install dev lint format typecheck test test-cov test-integration check \ - docker-build db-up db-down db-reset db-logs db-psql migrate + docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' -install: ## Installe les dependances du backend +install: ## Installe les dépendances du backend cd $(BACKEND) && uv sync --all-groups -dev: ## Lance l'API en rechargement a chaud +dev: ## Lance l'API en rechargement à chaud cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000 lint: ## Analyse statique du backend @@ -19,31 +19,31 @@ lint: ## Analyse statique du backend format: ## Formate et corrige le backend cd $(BACKEND) && uv run ruff format . && uv run ruff check --fix . -typecheck: ## Verifie le typage du backend +typecheck: ## Vérifie le typage du backend cd $(BACKEND) && uv run mypy app -test: ## Execute les tests backend ne demandant pas de base +test: ## Exécute les tests backend ne demandant pas de base cd $(BACKEND) && uv run pytest --cov-fail-under=85 -test-cov: ## Rapports de couverture HTML et XML, plus les resultats au format JUnit +test-cov: ## Rapports de couverture HTML et XML, plus les résultats au format JUnit cd $(BACKEND) && uv run pytest --cov-fail-under=85 --cov-report=html \ --cov-report=xml --junitxml=test-results/junit.xml -test-integration: ## Execute les tests exigeant une base joignable +test-integration: ## Exécute les tests exigeant une base joignable cd $(BACKEND) && uv run pytest -m integration -check: lint typecheck test ## Chaine de verification complete +check: lint typecheck test ## Chaîne de vérification complète docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) -db-up: ## Demarre la base PostgreSQL TimescaleDB +db-up: ## Démarre la base PostgreSQL TimescaleDB docker compose up -d db -db-down: ## Arrete la base en conservant ses donnees +db-down: ## Arrête la base en conservant ses données docker compose stop db -db-reset: ## Detruit la base et rejoue db/init +db-reset: ## Détruit la base et rejoue db/init docker compose down -v && docker compose up -d db db-logs: ## Suit les journaux de la base @@ -54,3 +54,6 @@ db-psql: ## Ouvre une session psql sur la base applicative migrate: ## Applique les migrations Alembic cd $(BACKEND) && uv run alembic upgrade head + +bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier + cd $(BACKEND) && uv run python -m app.cli create-admin --email $${EMAIL:?EMAIL=... requis} diff --git a/apps/backend/.env.example b/apps/backend/.env.example index cd96463..f36551e 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,6 +1,10 @@ APP_ENV=local -APP_DEBUG=true +APP_DEBUG=false APP_LOG_LEVEL=INFO + +# L'API refuse de démarrer tant que cette valeur reste un exemple ou fait moins de +# 32 caractères. Générer la vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))" APP_SECRET_KEY=change_me + APP_CORS_ORIGINS=http://localhost:4200 DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision diff --git a/apps/backend/alembic/versions/b1a7c3d9e240_comptes_applicatifs.py b/apps/backend/alembic/versions/b1a7c3d9e240_comptes_applicatifs.py new file mode 100644 index 0000000..db50a12 --- /dev/null +++ b/apps/backend/alembic/versions/b1a7c3d9e240_comptes_applicatifs.py @@ -0,0 +1,72 @@ +"""comptes applicatifs + +Revision ID: b1a7c3d9e240 +Revises: 5353c0e4f094 +Create Date: 2026-09-15 14:40:00.000000 + +Cree `app_user`, la table des comptes humains et de service. Le nom evite `user`, +mot reserve de PostgreSQL. `gen_random_uuid()` est au coeur de PG17, aucune +extension n'est necessaire. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "b1a7c3d9e240" +down_revision: str | Sequence[str] | None = "5353c0e4f094" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "app_user", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("password_hash", sa.Text(), nullable=False), + sa.Column("role", sa.Text(), nullable=False), + sa.Column("kind", sa.Text(), server_default=sa.text("'human'"), nullable=False), + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column( + "must_change_password", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), + sa.Column( + "credentials_changed_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("full_name", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint("email = lower(email)", name="ck_app_user_email_minuscule"), + sa.CheckConstraint( + "role in ('lecteur', 'operateur', 'admin')", name="ck_app_user_role" + ), + sa.CheckConstraint("kind in ('human', 'service')", name="ck_app_user_kind"), + sa.PrimaryKeyConstraint("id", name="pk_app_user"), + sa.UniqueConstraint("email", name="uq_app_user_email"), + ) + + +def downgrade() -> None: + op.drop_table("app_user") diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py new file mode 100644 index 0000000..74d7a50 --- /dev/null +++ b/apps/backend/app/cli.py @@ -0,0 +1,104 @@ +# Pourquoi : `create_admin()` est une commande et non une révision Alembic. Une révision qui +# insérerait un compte graverait son empreinte dans Git pour toujours, et son mot de passe +# serait connu de quiconque lit le dépôt. L'ADR 0001 pose par ailleurs qu'Alembic porte le +# schéma, pas les données. +# Piège : le mot de passe ne transite jamais par `argv`, visible de tout `ps`, ni par +# l'historique du shell. Il est saisi par `getpass` ou tiré au sort par la commande. + +import argparse +import asyncio +import secrets +import sys +from getpass import getpass + +from app.core.config import Settings, get_settings +from app.core.hashing import build_hasher +from app.core.roles import Role +from app.db.session import get_session_factory +from app.repositories.user import UserRepository + +LONGUEUR_MOT_DE_PASSE_GENERE = 24 +LONGUEUR_MINIMALE = 12 + + +async def create_admin( + settings: Settings, *, email: str, password: str, force: bool +) -> tuple[bool, str]: + hacheur = build_hasher( + time_cost=settings.argon2_time_cost, + memory_cost_kib=settings.argon2_memory_cost_kib, + parallelism=settings.argon2_parallelism, + max_concurrency=settings.argon2_max_concurrency, + ) + empreinte = await hacheur.hash(password) + + async with get_session_factory()() as session: + depot = UserRepository(session) + + if not force and await depot.count_active_admins() > 0: + return False, "Un administrateur actif existe déjà, relancer avec --force pour forcer" + + if await depot.get_by_email(email) is not None: + return False, f"Le compte {email} existe déjà" + + await depot.create( + email=email, + password_hash=empreinte, + role=Role.ADMIN, + must_change_password=True, + ) + await session.commit() + + return ( + True, + f"Administrateur {email.strip().lower()} créé, mot de passe à changer à la connexion", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python -m app.cli", description="Outils EnerVision") + sous_commandes = parser.add_subparsers(dest="commande", required=True) + + admin = sous_commandes.add_parser("create-admin", help="Crée le premier administrateur") + admin.add_argument("--email", required=True) + admin.add_argument( + "--generate", action="store_true", help="Tire un mot de passe au sort et l'affiche une fois" + ) + admin.add_argument( + "--force", action="store_true", help="Crée le compte même si un administrateur existe" + ) + return parser + + +def read_password(*, generate: bool) -> str: + if generate: + mot_de_passe = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_GENERE) + print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}") + return mot_de_passe + + mot_de_passe = getpass("Mot de passe : ") + if len(mot_de_passe) < LONGUEUR_MINIMALE: + raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères") + if mot_de_passe != getpass("Confirmation : "): + raise SystemExit("Les deux saisies diffèrent") + return mot_de_passe + + +def main(argv: list[str] | None = None) -> int: + arguments = build_parser().parse_args(argv) + mot_de_passe = read_password(generate=arguments.generate) + + succes, message = asyncio.run( + create_admin( + get_settings(), + email=arguments.email, + password=mot_de_passe, + force=arguments.force, + ) + ) + print(message) + return 0 if succes else 1 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index c3dbbe2..4731f81 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -1,10 +1,16 @@ from functools import lru_cache -from typing import Literal +from typing import Literal, Self -from pydantic import Field, SecretStr +from pydantic import Field, SecretStr, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict Environment = Literal["local", "dev", "staging", "prod"] +SameSite = Literal["lax", "strict", "none"] + +SECRET_KEY_MIN_LENGTH = 32 +SENTINELLES_INTERDITES = frozenset( + {"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"} +) class Settings(BaseSettings): @@ -27,6 +33,30 @@ class Settings(BaseSettings): database_pool_size: int = 5 database_max_overflow: int = 10 + jwt_issuer: str = "enervision-api" + jwt_audience: str = "enervision-web" + access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600) + refresh_token_ttl_seconds: int = Field(default=604800, ge=3600, le=2592000) + + refresh_cookie_name: str = "ev_refresh" + cookie_path: str = "/api/v1/auth" + cookie_samesite: SameSite = "strict" + cookie_secure: bool | None = None + + argon2_time_cost: int = Field(default=2, ge=1, le=10) + argon2_memory_cost_kib: int = Field(default=19456, ge=8192) + argon2_parallelism: int = Field(default=1, ge=1, le=4) + argon2_max_concurrency: int = Field(default=4, ge=1, le=32) + + login_window_seconds: int = Field(default=900, ge=60) + login_max_failures_per_identifier_and_ip: int = Field(default=5, ge=1) + login_max_failures_per_ip: int = Field(default=20, ge=1) + login_max_failures_per_identifier: int = Field(default=50, ge=1) + + trust_proxy_headers: bool = False + expose_api_docs: bool | None = None + metrics_token: SecretStr | None = None + @property def allowed_origins(self) -> list[str]: return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] @@ -35,6 +65,44 @@ class Settings(BaseSettings): def is_production(self) -> bool: return self.env == "prod" + @property + def cookies_are_secure(self) -> bool: + return self.env != "local" if self.cookie_secure is None else self.cookie_secure + + @property + def api_docs_are_exposed(self) -> bool: + if self.expose_api_docs is not None: + return self.expose_api_docs + return self.env not in ("staging", "prod") + + @model_validator(mode="after") + def _refuse_les_configurations_dangereuses(self) -> Self: + secret = self.secret_key.get_secret_value() + if len(secret) < SECRET_KEY_MIN_LENGTH: + raise ValueError( + f"APP_SECRET_KEY doit faire au moins {SECRET_KEY_MIN_LENGTH} caractères" + ) + if secret.strip().lower() in SENTINELLES_INTERDITES: + raise ValueError("APP_SECRET_KEY est une valeur d'exemple, il faut en générer une") + + # Piège : `create_app()` passe `debug` à FastAPI, qui renvoie alors la trace complète + # au client, et à l'engine, qui journalise le SQL et ses paramètres. + if self.debug and self.env in ("staging", "prod"): + raise ValueError("APP_DEBUG doit rester faux hors des environnements locaux") + + if "*" in self.cors_origins: + raise ValueError("APP_CORS_ORIGINS n'accepte pas de joker, les origines sont listées") + + # Sans origines, aucun middleware CORS n'est monté et la vérification d'`Origin` des + # routes d'authentification n'a plus de référentiel auquel comparer. + if self.env != "local" and not self.allowed_origins: + raise ValueError("APP_CORS_ORIGINS doit lister au moins une origine hors local") + + if self.cookie_samesite == "none" and not self.cookies_are_secure: + raise ValueError("Un cookie SameSite=None est rejeté par les navigateurs sans Secure") + + return self + @lru_cache def get_settings() -> Settings: diff --git a/apps/backend/app/core/cookies.py b/apps/backend/app/core/cookies.py new file mode 100644 index 0000000..f54dbff --- /dev/null +++ b/apps/backend/app/core/cookies.py @@ -0,0 +1,54 @@ +# Piège : le cookie de suppression doit reprendre exactement le nom et le `Path` du cookie +# posé, sinon le navigateur en garde une copie et la déconnexion n'est que cosmétique. +# `RefreshCookie.expired()` existe pour que les deux ne puissent pas diverger. + +from dataclasses import asdict, dataclass +from typing import Any, Self + +from app.core.config import SameSite, Settings + +SECURE_PREFIX = "__Secure-" + + +@dataclass(frozen=True, slots=True) +class RefreshCookie: + key: str + value: str + max_age: int + path: str + secure: bool + httponly: bool + samesite: SameSite + + @classmethod + def build(cls, settings: Settings, value: str) -> Self: + return cls( + key=cookie_name(settings), + value=value, + max_age=settings.refresh_token_ttl_seconds, + path=settings.cookie_path, + secure=settings.cookies_are_secure, + httponly=True, + samesite=settings.cookie_samesite, + ) + + @classmethod + def expired(cls, settings: Settings) -> Self: + return cls( + key=cookie_name(settings), + value="", + max_age=0, + path=settings.cookie_path, + secure=settings.cookies_are_secure, + httponly=True, + samesite=settings.cookie_samesite, + ) + + def as_kwargs(self) -> dict[str, Any]: + return asdict(self) + + +def cookie_name(settings: Settings) -> str: + if settings.cookies_are_secure: + return f"{SECURE_PREFIX}{settings.refresh_cookie_name}" + return settings.refresh_cookie_name diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 2ac405d..dd6cc73 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,2 +1,6 @@ # Piège : tout modèle absent de ce module reste invisible de `alembic revision # --autogenerate`, qui générerait alors un drop de sa table. + +from app.models.user import AppUser + +__all__ = ["AppUser"] diff --git a/apps/backend/app/models/user.py b/apps/backend/app/models/user.py new file mode 100644 index 0000000..b2dcf4b --- /dev/null +++ b/apps/backend/app/models/user.py @@ -0,0 +1,50 @@ +# Contrainte : la table s'appelle `app_user` et non `user`, qui est un mot réservé PostgreSQL, +# raccourci de `CURRENT_USER`. Le nom rappelle aussi qu'il s'agit d'un compte applicatif, par +# opposition au rôle PostgreSQL qui porte, lui, le cantonnement des accès. + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, CheckConstraint, DateTime, String, Text, func, text +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.roles import AccountKind, Role +from app.db.base import Base + +ROLES_AUTORISES = ", ".join(f"'{role.value}'" for role in Role) +NATURES_AUTORISEES = ", ".join(f"'{nature.value}'" for nature in AccountKind) + + +class AppUser(Base): + __tablename__ = "app_user" + __table_args__ = ( + CheckConstraint("email = lower(email)", name="ck_app_user_email_minuscule"), + CheckConstraint(f"role in ({ROLES_AUTORISES})", name="ck_app_user_role"), + CheckConstraint(f"kind in ({NATURES_AUTORISEES})", name="ck_app_user_kind"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid() + ) + email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(Text, nullable=False) + role: Mapped[str] = mapped_column(Text, nullable=False) + kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'human'")) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + must_change_password: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("false") + ) + # Une seule colonne couvre le changement de mot de passe, le changement de rôle et la + # désactivation : tout jeton émis avant cet instant est périmé. + credentials_changed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + full_name: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/apps/backend/app/repositories/user.py b/apps/backend/app/repositories/user.py new file mode 100644 index 0000000..9155db1 --- /dev/null +++ b/apps/backend/app/repositories/user.py @@ -0,0 +1,97 @@ +# Piège : `set_role()` et `set_active()` avancent `credentials_changed_at`. C'est ce qui rend +# un changement de rôle ou une désactivation effectifs à la requête suivante au lieu d'attendre +# l'expiration du jeton d'accès. Une mise à jour qui l'oublierait laisserait 15 minutes de +# privilèges périmés. + +from collections.abc import Sequence +from uuid import UUID + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.roles import AccountKind, Role +from app.models.user import AppUser + + +class UserRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_email(self, email: str) -> AppUser | None: + requete = select(AppUser).where(AppUser.email == email.strip().lower()) + return (await self._session.execute(requete)).scalar_one_or_none() + + async def get_by_id(self, user_id: UUID) -> AppUser | None: + return await self._session.get(AppUser, user_id) + + async def list_all(self) -> Sequence[AppUser]: + requete = select(AppUser).order_by(AppUser.email) + return (await self._session.execute(requete)).scalars().all() + + async def count_active_admins(self) -> int: + requete = ( + select(func.count()) + .select_from(AppUser) + .where(AppUser.role == Role.ADMIN.value, AppUser.is_active.is_(True)) + ) + return (await self._session.execute(requete)).scalar_one() + + async def create( + self, + *, + email: str, + password_hash: str, + role: Role, + kind: AccountKind = AccountKind.HUMAIN, + full_name: str | None = None, + must_change_password: bool = False, + ) -> AppUser: + compte = AppUser( + email=email.strip().lower(), + password_hash=password_hash, + role=role.value, + kind=kind.value, + full_name=full_name, + must_change_password=must_change_password, + ) + self._session.add(compte) + await self._session.flush() + return compte + + async def update_password( + self, user_id: UUID, password_hash: str, *, must_change_password: bool + ) -> None: + await self._session.execute( + update(AppUser) + .where(AppUser.id == user_id) + .values( + password_hash=password_hash, + must_change_password=must_change_password, + credentials_changed_at=func.now(), + ) + ) + + async def rehash_password(self, user_id: UUID, password_hash: str) -> None: + # Un simple recalcul avec des paramètres Argon2 plus récents ne périme aucun jeton. + await self._session.execute( + update(AppUser).where(AppUser.id == user_id).values(password_hash=password_hash) + ) + + async def touch_last_login(self, user_id: UUID) -> None: + await self._session.execute( + update(AppUser).where(AppUser.id == user_id).values(last_login_at=func.now()) + ) + + async def set_role(self, user_id: UUID, role: Role) -> None: + await self._session.execute( + update(AppUser) + .where(AppUser.id == user_id) + .values(role=role.value, credentials_changed_at=func.now()) + ) + + async def set_active(self, user_id: UUID, *, is_active: bool) -> None: + await self._session.execute( + update(AppUser) + .where(AppUser.id == user_id) + .values(is_active=is_active, credentials_changed_at=func.now()) + ) diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index 70ba87a..bc8ccfb 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -22,7 +22,7 @@ def environment() -> Iterator[None]: "APP_DEBUG": "false", "APP_LOG_LEVEL": "WARNING", "APP_CORS_ORIGINS": "", - "APP_SECRET_KEY": "secret-de-test", + "APP_SECRET_KEY": "secret-de-test-assez-long-pour-le-validateur", } ) os.environ.setdefault( diff --git a/apps/backend/tests/core/test_config.py b/apps/backend/tests/core/test_config.py new file mode 100644 index 0000000..6c67120 --- /dev/null +++ b/apps/backend/tests/core/test_config.py @@ -0,0 +1,78 @@ +import pytest +from pydantic import ValidationError + +from tests.factories import make_settings + +SECRET_VALIDE = "un-secret-de-test-de-plus-de-trente-deux-caracteres" + + +@pytest.mark.parametrize( + "surcharges", + [ + {"secret_key": "trop-court"}, + {"secret_key": "change_me"}, + {"env": "prod", "debug": True, "cors_origins": "https://enervision.fr"}, + {"cors_origins": "*"}, + {"env": "prod", "cors_origins": ""}, + {"cookie_samesite": "none", "cookie_secure": False}, + ], + ids=[ + "secret_trop_court", + "secret_sentinelle", + "debug_en_production", + "joker_dans_les_origines", + "origines_vides_hors_local", + "samesite_none_sans_secure", + ], +) +def test_settings_refuses_to_build_when_the_configuration_is_unsafe( + surcharges: dict[str, object], +) -> None: + with pytest.raises(ValidationError): + make_settings(**surcharges) + + +def test_settings_accepts_debug_in_local_environment() -> None: + settings = make_settings(env="local", debug=True) + + assert settings.debug is True + + +@pytest.mark.parametrize( + ("env", "attendu"), + [("local", False), ("dev", True), ("staging", True), ("prod", True)], + ids=["local", "dev", "staging", "production"], +) +def test_cookies_are_secure_follows_the_environment(env: str, attendu: bool) -> None: + settings = make_settings(env=env, cors_origins="https://enervision.fr") + + assert settings.cookies_are_secure is attendu + + +def test_cookies_are_secure_honours_an_explicit_override() -> None: + settings = make_settings(env="prod", cors_origins="https://enervision.fr", cookie_secure=False) + + assert settings.cookies_are_secure is False + + +@pytest.mark.parametrize( + ("env", "attendu"), + [("local", True), ("dev", True), ("staging", False), ("prod", False)], + ids=["local", "dev", "staging", "production"], +) +def test_api_docs_are_exposed_closes_staging_and_production(env: str, attendu: bool) -> None: + settings = make_settings(env=env, cors_origins="https://enervision.fr") + + assert settings.api_docs_are_exposed is attendu + + +def test_api_docs_are_exposed_honours_an_explicit_override() -> None: + settings = make_settings(env="prod", cors_origins="https://enervision.fr", expose_api_docs=True) + + assert settings.api_docs_are_exposed is True + + +def test_allowed_origins_splits_and_trims_the_list() -> None: + settings = make_settings(cors_origins=" http://localhost:4200 , https://enervision.fr ") + + assert settings.allowed_origins == ["http://localhost:4200", "https://enervision.fr"] diff --git a/apps/backend/tests/core/test_cookies.py b/apps/backend/tests/core/test_cookies.py new file mode 100644 index 0000000..7454e47 --- /dev/null +++ b/apps/backend/tests/core/test_cookies.py @@ -0,0 +1,58 @@ +from app.core.cookies import RefreshCookie, cookie_name +from tests.factories import make_settings + + +def test_build_marks_the_cookie_http_only_and_scopes_it_to_the_auth_routes() -> None: + settings = make_settings(env="local") + + cookie = RefreshCookie.build(settings, "un-secret-opaque") + + assert cookie.httponly is True + assert cookie.samesite == "strict" + assert cookie.path == "/api/v1/auth" + assert cookie.max_age == settings.refresh_token_ttl_seconds + + +def test_build_prefixes_and_secures_the_cookie_outside_local() -> None: + settings = make_settings(env="prod", cors_origins="https://enervision.fr") + + cookie = RefreshCookie.build(settings, "un-secret-opaque") + + assert cookie.secure is True + assert cookie.key.startswith("__Secure-") + + +def test_build_leaves_the_cookie_unprefixed_in_local() -> None: + settings = make_settings(env="local") + + cookie = RefreshCookie.build(settings, "un-secret-opaque") + + assert cookie.key == "ev_refresh" + + +def test_expired_reuses_the_exact_name_and_path_of_the_posted_cookie() -> None: + settings = make_settings(env="prod", cors_origins="https://enervision.fr") + + pose = RefreshCookie.build(settings, "un-secret-opaque") + suppression = RefreshCookie.expired(settings) + + assert suppression.key == pose.key + assert suppression.path == pose.path + assert suppression.secure == pose.secure + assert suppression.samesite == pose.samesite + assert suppression.max_age == 0 + assert suppression.value == "" + + +def test_as_kwargs_matches_the_starlette_set_cookie_signature() -> None: + settings = make_settings(env="local") + + arguments = RefreshCookie.build(settings, "un-secret-opaque").as_kwargs() + + assert set(arguments) == {"key", "value", "max_age", "path", "secure", "httponly", "samesite"} + + +def test_cookie_name_follows_the_configured_name() -> None: + settings = make_settings(env="local", refresh_cookie_name="autre_nom") + + assert cookie_name(settings) == "autre_nom" diff --git a/apps/backend/tests/factories.py b/apps/backend/tests/factories.py index 3098863..17433c5 100644 --- a/apps/backend/tests/factories.py +++ b/apps/backend/tests/factories.py @@ -7,7 +7,7 @@ SETTINGS_DE_TEST: dict[str, Any] = { "debug": False, "log_level": "WARNING", "cors_origins": "", - "secret_key": "secret-de-test", + "secret_key": "secret-de-test-assez-long-pour-le-validateur", "database_url": "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test", } From ef933bea1ab59623ad63e5b0cfe27926619c3883 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:41:25 +0200 Subject: [PATCH 05/13] =?UTF-8?q?feat(backend):=20authentifie=20par=20mot?= =?UTF-8?q?=20de=20passe=20et=20refuse=20les=20routes=20par=20d=C3=A9faut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...tatives_de_connexion_et_journal_d_audit.py | 119 +++++++++ apps/backend/app/api/deps.py | 166 ++++++++++++- apps/backend/app/api/errors.py | 47 ++++ apps/backend/app/api/v1/endpoints/auth.py | 56 +++++ apps/backend/app/api/v1/router.py | 5 +- apps/backend/app/main.py | 3 + apps/backend/app/models/__init__.py | 4 +- apps/backend/app/models/audit_log.py | 64 +++++ apps/backend/app/models/login_attempt.py | 44 ++++ apps/backend/app/repositories/audit_log.py | 62 +++++ .../backend/app/repositories/login_attempt.py | 67 +++++ apps/backend/app/repositories/user.py | 6 +- apps/backend/app/schemas/auth.py | 44 ++++ apps/backend/app/services/auth.py | 171 +++++++++++++ apps/backend/pyproject.toml | 2 +- apps/backend/tests/api/test_auth.py | 106 ++++++++ apps/backend/tests/api/test_authorization.py | 113 +++++++++ .../tests/api/test_route_protection.py | 74 ++++++ .../tests/repositories/test_audit_log.py | 125 ++++++++++ .../tests/repositories/test_login_attempt.py | 118 +++++++++ apps/backend/tests/repositories/test_user.py | 196 +++++++++++++++ apps/backend/tests/services/test_auth.py | 234 ++++++++++++++++++ apps/backend/tests/test_cli.py | 57 +++++ apps/backend/uv.lock | 31 ++- 24 files changed, 1904 insertions(+), 10 deletions(-) create mode 100644 apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py create mode 100644 apps/backend/app/api/errors.py create mode 100644 apps/backend/app/api/v1/endpoints/auth.py create mode 100644 apps/backend/app/models/audit_log.py create mode 100644 apps/backend/app/models/login_attempt.py create mode 100644 apps/backend/app/repositories/audit_log.py create mode 100644 apps/backend/app/repositories/login_attempt.py create mode 100644 apps/backend/app/schemas/auth.py create mode 100644 apps/backend/app/services/auth.py create mode 100644 apps/backend/tests/api/test_auth.py create mode 100644 apps/backend/tests/api/test_authorization.py create mode 100644 apps/backend/tests/api/test_route_protection.py create mode 100644 apps/backend/tests/repositories/test_audit_log.py create mode 100644 apps/backend/tests/repositories/test_login_attempt.py create mode 100644 apps/backend/tests/repositories/test_user.py create mode 100644 apps/backend/tests/services/test_auth.py create mode 100644 apps/backend/tests/test_cli.py diff --git a/apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py b/apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py new file mode 100644 index 0000000..59ffb85 --- /dev/null +++ b/apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py @@ -0,0 +1,119 @@ +"""tentatives de connexion et journal d audit + +Revision ID: 517053a3c044 +Revises: b1a7c3d9e240 +Create Date: 2026-09-15 14:31:07.966180 + +Deux tables aux vocations opposees. `login_attempt` est le compteur de la limitation +de debit : son volume est pilote par l'attaquant, donc elle se purge. `audit_log` est +en ajout seul, garanti par deux declencheurs. + +Le declencheur TRUNCATE n'est pas redondant : TRUNCATE ne passe pas par les +declencheurs de ligne. Et RAISE EXCEPTION plutot qu'un RETURN NULL, qui annulerait +l'operation silencieusement. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "517053a3c044" +down_revision: str | Sequence[str] | None = "b1a7c3d9e240" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +FONCTION_AJOUT_SEUL = """ +CREATE FUNCTION audit_log_append_only() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'audit_log est en ajout seul : % interdit', TG_OP; +END +$$ LANGUAGE plpgsql; +""" + +DECLENCHEUR_LIGNE = """ +CREATE TRIGGER audit_log_no_update_delete + BEFORE UPDATE OR DELETE ON audit_log + FOR EACH ROW EXECUTE FUNCTION audit_log_append_only(); +""" + +DECLENCHEUR_TRUNCATE = """ +CREATE TRIGGER audit_log_no_truncate + BEFORE TRUNCATE ON audit_log + FOR EACH STATEMENT EXECUTE FUNCTION audit_log_append_only(); +""" + + +def upgrade() -> None: + op.create_table( + "login_attempt", + sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("email_tried", sa.String(length=320), nullable=False), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("outcome", sa.Text(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=True), + sa.CheckConstraint( + "outcome in ('success', 'bad_credentials', 'throttled', 'inactive')", + name="ck_login_attempt_outcome", + ), + sa.PrimaryKeyConstraint("id", name="pk_login_attempt"), + ) + op.create_index( + "ix_login_attempt_email_date", "login_attempt", ["email_tried", "occurred_at"] + ) + op.create_index("ix_login_attempt_ip_date", "login_attempt", ["client_ip", "occurred_at"]) + + op.create_table( + "audit_log", + sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("actor_id", sa.UUID(), nullable=True), + sa.Column("actor_email", sa.Text(), nullable=True), + sa.Column("actor_role", sa.Text(), nullable=True), + sa.Column("action", sa.Text(), nullable=False), + sa.Column("target_type", sa.Text(), nullable=True), + sa.Column("target_id", sa.Text(), nullable=True), + sa.Column("outcome", sa.Text(), nullable=False), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.Column( + "detail", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("jsonb_build_object()"), + nullable=False, + ), + sa.CheckConstraint("outcome in ('success', 'failure')", name="ck_audit_log_outcome"), + sa.PrimaryKeyConstraint("id", name="pk_audit_log"), + ) + op.create_index("ix_audit_log_date", "audit_log", ["occurred_at"]) + op.create_index("ix_audit_log_action_date", "audit_log", ["action", "occurred_at"]) + + op.execute(FONCTION_AJOUT_SEUL) + op.execute(DECLENCHEUR_LIGNE) + op.execute(DECLENCHEUR_TRUNCATE) + + +def downgrade() -> None: + op.execute("DROP TRIGGER IF EXISTS audit_log_no_truncate ON audit_log;") + op.execute("DROP TRIGGER IF EXISTS audit_log_no_update_delete ON audit_log;") + op.execute("DROP FUNCTION IF EXISTS audit_log_append_only();") + + op.drop_index("ix_audit_log_action_date", table_name="audit_log") + op.drop_index("ix_audit_log_date", table_name="audit_log") + op.drop_table("audit_log") + + op.drop_index("ix_login_attempt_ip_date", table_name="login_attempt") + op.drop_index("ix_login_attempt_email_date", table_name="login_attempt") + op.drop_table("login_attempt") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index a25b1e1..a35d628 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -1,10 +1,174 @@ +# Piège : `get_current_principal()` relit le compte en base à chaque requête au lieu de faire +# confiance aux claims. C'est le renoncement assumé à la propriété « sans état » : sur un seul +# service et une seule base, elle n'achetait rien, et la lecture par clé primaire coûte moins +# d'un pour cent du budget d'une requête. Ce qu'elle achète, c'est la révocation immédiate. +# Piège : le `Principal` est construit depuis la ligne, jamais depuis le claim `role`. Un claim +# périmé ne peut donc pas provoquer d'élévation de privilège. + +from collections.abc import Callable +from datetime import timedelta +from functools import lru_cache from typing import Annotated -from fastapi import Depends +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import Settings, get_settings +from app.core.hashing import Argon2Hasher, build_hasher +from app.core.principal import Principal +from app.core.roles import AccountKind, Role, has_at_least +from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy +from app.core.security import decode_access_token as decode_token from app.db.session import get_session +from app.repositories.audit_log import AuditLogRepository +from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.user import UserRepository +from app.services.auth import AuthService, LoginPolicy SessionDep = Annotated[AsyncSession, Depends(get_session)] SettingsDep = Annotated[Settings, Depends(get_settings)] + +CODE_CHANGEMENT_REQUIS = "password_change_required" + +_porteur = HTTPBearer(auto_error=False, scheme_name="Jeton d'accès") +CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)] + + +def _non_authentifie(description: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentification requise", + headers={"WWW-Authenticate": f'Bearer error="{description}"'}, + ) + + +def get_token_policy(settings: SettingsDep) -> TokenPolicy: + return TokenPolicy( + secret=settings.secret_key.get_secret_value(), + issuer=settings.jwt_issuer, + audience=settings.jwt_audience, + access_ttl=timedelta(seconds=settings.access_token_ttl_seconds), + ) + + +# Construire un `Argon2Hasher` calcule un haché leurre, donc 17 ms : il est mis en cache sur +# les paramètres plutôt que reconstruit à chaque requête. +@lru_cache +def _hasher_cache( + time_cost: int, memory_cost_kib: int, parallelism: int, max_concurrency: int +) -> Argon2Hasher: + return build_hasher( + time_cost=time_cost, + memory_cost_kib=memory_cost_kib, + parallelism=parallelism, + max_concurrency=max_concurrency, + ) + + +def get_hasher(settings: SettingsDep) -> Argon2Hasher: + return _hasher_cache( + settings.argon2_time_cost, + settings.argon2_memory_cost_kib, + settings.argon2_parallelism, + settings.argon2_max_concurrency, + ) + + +def get_client_ip(request: Request, settings: SettingsDep) -> str | None: + # Derrière un proxy, `request.client.host` vaut l'IP du proxy : le compteur par IP + # deviendrait global, donc un déni de service auto-infligé. Le dernier élément est le seul + # qu'un proxy de confiance ait écrit, les précédents sont fournis par le client. + if settings.trust_proxy_headers: + transmis = request.headers.get("x-forwarded-for") + if transmis: + return transmis.split(",")[-1].strip() + return request.client.host if request.client else None + + +def get_auth_service( + session: SessionDep, + settings: SettingsDep, + hasher: Annotated[Argon2Hasher, Depends(get_hasher)], + token_policy: Annotated[TokenPolicy, Depends(get_token_policy)], +) -> AuthService: + return AuthService( + users=UserRepository(session), + attempts=LoginAttemptRepository(session), + audit=AuditLogRepository(session), + hasher=hasher, + transaction=session, + token_policy=token_policy, + login_policy=LoginPolicy( + window_seconds=settings.login_window_seconds, + max_failures_per_identifier_and_ip=(settings.login_max_failures_per_identifier_and_ip), + max_failures_per_ip=settings.login_max_failures_per_ip, + max_failures_per_identifier=settings.login_max_failures_per_identifier, + ), + ) + + +AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] + + +async def get_current_principal( + credentials: CredentialsDep, + session: SessionDep, + token_policy: Annotated[TokenPolicy, Depends(get_token_policy)], +) -> Principal: + if credentials is None: + raise _non_authentifie("invalid_request") + + try: + claims = decode_token(token_policy, credentials.credentials) + except TokenExpiredError as erreur: + raise _non_authentifie("expired") from erreur + except TokenInvalidError as erreur: + raise _non_authentifie("invalid_token") from erreur + + compte = await UserRepository(session).get_by_id(claims.subject) + if compte is None or not compte.is_active: + raise _non_authentifie("invalid_token") + if claims.issued_at < compte.credentials_changed_at: + raise _non_authentifie("token_stale") + if claims.role != compte.role: + raise _non_authentifie("token_stale") + + return Principal( + id=compte.id, + email=compte.email, + role=Role(compte.role), + kind=AccountKind(compte.kind), + must_change_password=compte.must_change_password, + ) + + +CurrentPrincipalDep = Annotated[Principal, Depends(get_current_principal)] + + +def require_role(minimum: Role) -> Callable[[Principal], Principal]: + def garde(principal: CurrentPrincipalDep) -> Principal: + if principal.must_change_password: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=CODE_CHANGEMENT_REQUIS + ) + if not has_at_least(principal.role, minimum): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Droits insuffisants") + return principal + + return garde + + +LecteurDep = Annotated[Principal, Depends(require_role(Role.LECTEUR))] +OperateurDep = Annotated[Principal, Depends(require_role(Role.OPERATEUR))] +AdminDep = Annotated[Principal, Depends(require_role(Role.ADMIN))] + + +def require_trusted_origin(request: Request, settings: SettingsDep) -> None: + # Un navigateur envoie toujours `Origin` sur une requête non sûre. Son absence signale un + # client hors navigateur, qui ne détient aucun cookie de victime : rien à protéger. + origine = request.headers.get("origin") + if origine is None: + return + if origine not in settings.allowed_origins: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Origine refusée") diff --git a/apps/backend/app/api/errors.py b/apps/backend/app/api/errors.py new file mode 100644 index 0000000..7485b55 --- /dev/null +++ b/apps/backend/app/api/errors.py @@ -0,0 +1,47 @@ +# Piège : la réponse 422 par défaut de FastAPI contient la clé `input`, c'est-à-dire la valeur +# rejetée. Sur `/auth/login`, un corps malformé renverrait donc le mot de passe au client et le +# déposerait dans les journaux d'erreur. `validation_error_handler()` ne laisse passer que le +# champ fautif et le type d'erreur. + +import uuid +from typing import Any + +from fastapi import FastAPI, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from app.core.logging import get_logger + +logger = get_logger(__name__) + + +async def validation_error_handler(_: Request, exception: RequestValidationError) -> JSONResponse: + champs: list[dict[str, Any]] = [ + { + "champ": ".".join(str(element) for element in erreur["loc"]), + "type": erreur["type"], + } + for erreur in exception.errors() + ] + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": champs} + ) + + +async def unhandled_error_handler(request: Request, exception: Exception) -> JSONResponse: + correlation = uuid.uuid4().hex + logger.exception( + "erreur non gérée correlation=%s methode=%s chemin=%s", + correlation, + request.method, + request.url.path, + ) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Erreur interne", "correlation": correlation}, + ) + + +def register_error_handlers(application: FastAPI) -> None: + application.add_exception_handler(RequestValidationError, validation_error_handler) # type: ignore[arg-type] + application.add_exception_handler(Exception, unhandled_error_handler) diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..0def0d4 --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -0,0 +1,56 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status + +from app.api.deps import AuthServiceDep, CurrentPrincipalDep, get_client_ip +from app.core.logging import get_logger +from app.schemas.auth import LoginRequest, PrincipalResponse, TokenResponse +from app.services.auth import InvalidCredentialsError, RateLimitedError + +router = APIRouter() +logger = get_logger(__name__) + +DETAIL_IDENTIFIANTS = "Identifiants invalides" + + +@router.post("/login", response_model=TokenResponse, summary="Ouvre une session") +async def login( + payload: LoginRequest, + request: Request, + response: Response, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> TokenResponse: + # Une réponse d'authentification ne doit jamais être conservée par un intermédiaire. + response.headers["Cache-Control"] = "no-store" + agent = request.headers.get("user-agent") + + try: + session = await service.authenticate( + email=payload.email, + password=payload.password, + client_ip=client_ip, + user_agent=agent, + ) + except RateLimitedError as erreur: + logger.warning("auth.rate_limited email=%s ip=%s", payload.email, client_ip) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Trop de tentatives, réessayez plus tard", + headers={"Retry-After": str(erreur.retry_after)}, + ) from erreur + except InvalidCredentialsError as erreur: + logger.warning("auth.login.failure email=%s ip=%s", payload.email, client_ip) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_IDENTIFIANTS + ) from erreur + + logger.info("auth.login.success user_id=%s ip=%s", session.principal.id, client_ip) + return TokenResponse( + access_token=session.access_token, + expires_in=session.expires_in, + principal=PrincipalResponse.from_principal(session.principal), + ) + + +@router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté") +async def me(principal: CurrentPrincipalDep) -> PrincipalResponse: + return PrincipalResponse.from_principal(principal) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 8571d8f..473a024 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,6 +1,7 @@ from fastapi import APIRouter -from app.api.v1.endpoints import health +from app.api.v1.endpoints import auth, health api_router = APIRouter() -api_router.include_router(health.router, prefix="/health") +api_router.include_router(health.router, prefix="/health", tags=["health"]) +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 1008100..2ddf1dd 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -5,6 +5,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from prometheus_fastapi_instrumentator import Instrumentator +from app.api.errors import register_error_handlers from app.api.v1.router import api_router from app.core.config import Settings, get_settings from app.core.logging import configure_logging, get_logger @@ -46,6 +47,8 @@ def create_app(settings: Settings | None = None) -> FastAPI: allow_headers=["*"], ) + register_error_handlers(application) + Instrumentator().instrument(application).expose( application, endpoint="/metrics", include_in_schema=False ) diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index dd6cc73..222295d 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,6 +1,8 @@ # Piège : tout modèle absent de ce module reste invisible de `alembic revision # --autogenerate`, qui générerait alors un drop de sa table. +from app.models.audit_log import AuditLog +from app.models.login_attempt import LoginAttempt from app.models.user import AppUser -__all__ = ["AppUser"] +__all__ = ["AppUser", "AuditLog", "LoginAttempt"] diff --git a/apps/backend/app/models/audit_log.py b/apps/backend/app/models/audit_log.py new file mode 100644 index 0000000..5775f5e --- /dev/null +++ b/apps/backend/app/models/audit_log.py @@ -0,0 +1,64 @@ +# Pourquoi : `actor_id` ne porte volontairement aucune clé étrangère. Une contrainte +# `ON DELETE SET NULL` déclencherait un UPDATE que le déclencheur d'ajout seul refuserait, donc +# la suppression d'un compte échouerait ; une contrainte `NO ACTION` interdirait toute +# suppression. `actor_email` et `actor_role` sont dénormalisés pour la même raison : le journal +# dit ce qui était vrai au moment de l'acte, pas ce qui est vrai aujourd'hui. + +import uuid +from datetime import datetime +from enum import StrEnum +from typing import Any + +from sqlalchemy import BigInteger, CheckConstraint, DateTime, Identity, Index, Text, func +from sqlalchemy.dialects.postgresql import INET, JSONB +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AuditOutcome(StrEnum): + SUCCES = "success" + ECHEC = "failure" + + +class AuditAction(StrEnum): + COMPTE_CREE = "user.created" + COMPTE_ROLE_CHANGE = "user.role_changed" + COMPTE_DESACTIVE = "user.disabled" + COMPTE_ACTIVE = "user.enabled" + COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin" + COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed" + REFRESH_REUTILISE = "auth.refresh_reuse_detected" + SESSIONS_REVOQUEES = "auth.all_sessions_revoked" + LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled" + ADMIN_AMORCE = "bootstrap.admin_created" + + +ISSUES_AUTORISEES = ", ".join(f"'{issue.value}'" for issue in AuditOutcome) + + +class AuditLog(Base): + __tablename__ = "audit_log" + __table_args__ = ( + CheckConstraint(f"outcome in ({ISSUES_AUTORISEES})", name="ck_audit_log_outcome"), + Index("ix_audit_log_date", "occurred_at"), + Index("ix_audit_log_action_date", "action", "occurred_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + actor_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True) + actor_email: Mapped[str | None] = mapped_column(Text, nullable=True) + actor_role: Mapped[str | None] = mapped_column(Text, nullable=True) + action: Mapped[str] = mapped_column(Text, nullable=False) + target_type: Mapped[str | None] = mapped_column(Text, nullable=True) + target_id: Mapped[str | None] = mapped_column(Text, nullable=True) + outcome: Mapped[str] = mapped_column(Text, nullable=False) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) + detail: Mapped[dict[str, Any]] = mapped_column( + JSONB, nullable=False, server_default=func.jsonb_build_object() + ) diff --git a/apps/backend/app/models/login_attempt.py b/apps/backend/app/models/login_attempt.py new file mode 100644 index 0000000..f4b7701 --- /dev/null +++ b/apps/backend/app/models/login_attempt.py @@ -0,0 +1,44 @@ +# Pourquoi : les tentatives vivent ici et non dans `audit_log`, qui est en ajout seul. Leur +# volume est piloté par l'attaquant : une force brute y écrirait des millions de lignes +# indestructibles. Cette table-ci se purge, et c'est aussi le compteur de la limitation. +# Piège : la tentative est enregistrée même quand l'email est inconnu, sinon le 429 dirait +# qu'un compte existe. + +import uuid +from datetime import datetime +from enum import StrEnum + +from sqlalchemy import BigInteger, CheckConstraint, DateTime, Identity, Index, String, Text, func +from sqlalchemy.dialects.postgresql import INET +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class LoginOutcome(StrEnum): + SUCCES = "success" + IDENTIFIANTS_INVALIDES = "bad_credentials" + LIMITE = "throttled" + COMPTE_INDISPONIBLE = "inactive" + + +ISSUES_AUTORISEES = ", ".join(f"'{issue.value}'" for issue in LoginOutcome) + + +class LoginAttempt(Base): + __tablename__ = "login_attempt" + __table_args__ = ( + CheckConstraint(f"outcome in ({ISSUES_AUTORISEES})", name="ck_login_attempt_outcome"), + Index("ix_login_attempt_email_date", "email_tried", "occurred_at"), + Index("ix_login_attempt_ip_date", "client_ip", "occurred_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + email_tried: Mapped[str] = mapped_column(String(320), nullable=False) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) + outcome: Mapped[str] = mapped_column(Text, nullable=False) + user_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True) diff --git a/apps/backend/app/repositories/audit_log.py b/apps/backend/app/repositories/audit_log.py new file mode 100644 index 0000000..aa00f72 --- /dev/null +++ b/apps/backend/app/repositories/audit_log.py @@ -0,0 +1,62 @@ +# Piège : `detail` passe par une liste blanche de clés et jamais par un `dict(**kwargs)`. La +# table est en ajout seul : une clé inattendue qui porterait un secret ou une donnée +# personnelle ne pourrait plus en être retirée. + +from collections.abc import Mapping +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.principal import Principal +from app.models.audit_log import AuditAction, AuditLog, AuditOutcome + +CLES_DE_DETAIL_AUTORISEES = frozenset( + { + "email", + "role_avant", + "role_apres", + "famille", + "motif", + "source", + "sessions_revoquees", + } +) + + +def assemble_detail(brut: Mapping[str, Any] | None) -> dict[str, Any]: + if not brut: + return {} + return {cle: valeur for cle, valeur in brut.items() if cle in CLES_DE_DETAIL_AUTORISEES} + + +class AuditLogRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def record( + self, + *, + action: AuditAction, + outcome: AuditOutcome = AuditOutcome.SUCCES, + actor: Principal | None = None, + actor_label: str | None = None, + target_type: str | None = None, + target_id: str | None = None, + client_ip: str | None = None, + user_agent: str | None = None, + detail: Mapping[str, Any] | None = None, + ) -> None: + self._session.add( + AuditLog( + actor_id=actor.id if actor else None, + actor_email=actor.email if actor else actor_label, + actor_role=actor.role.value if actor else None, + action=action.value, + target_type=target_type, + target_id=target_id, + outcome=outcome.value, + client_ip=client_ip, + user_agent=user_agent, + detail=assemble_detail(detail), + ) + ) diff --git a/apps/backend/app/repositories/login_attempt.py b/apps/backend/app/repositories/login_attempt.py new file mode 100644 index 0000000..8f8df09 --- /dev/null +++ b/apps/backend/app/repositories/login_attempt.py @@ -0,0 +1,67 @@ +# Pourquoi : les trois compteurs tiennent en une seule requête, grâce aux clauses FILTER de +# PostgreSQL. Trois `count(*)` séparés feraient trois allers-retours sur le chemin critique de +# la connexion, qui est justement celui qu'un attaquant martèle. + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from uuid import UUID + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.login_attempt import LoginAttempt, LoginOutcome + + +@dataclass(frozen=True, slots=True) +class FailureCounts: + per_identifier_and_ip: int + per_ip: int + per_identifier: int + + +class LoginAttemptRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def record( + self, + *, + email: str, + client_ip: str | None, + outcome: LoginOutcome, + user_id: UUID | None = None, + ) -> None: + self._session.add( + LoginAttempt( + email_tried=email.strip().lower(), + client_ip=client_ip, + outcome=outcome.value, + user_id=user_id, + ) + ) + + async def count_recent_failures( + self, *, email: str, client_ip: str | None, window_seconds: int + ) -> FailureCounts: + identifiant = email.strip().lower() + meme_email = LoginAttempt.email_tried == identifiant + meme_ip = LoginAttempt.client_ip == client_ip + + requete = select( + func.count().filter(and_(meme_email, meme_ip)), + func.count().filter(meme_ip), + func.count().filter(meme_email), + ).where( + LoginAttempt.outcome != LoginOutcome.SUCCES.value, + LoginAttempt.occurred_at > datetime.now(UTC) - timedelta(seconds=window_seconds), + meme_email | meme_ip, + ) + + par_identifiant_et_ip, par_ip, par_identifiant = ( + await self._session.execute(requete) + ).one() + return FailureCounts( + per_identifier_and_ip=par_identifiant_et_ip, + per_ip=par_ip, + per_identifier=par_identifiant, + ) diff --git a/apps/backend/app/repositories/user.py b/apps/backend/app/repositories/user.py index 9155db1..eaac079 100644 --- a/apps/backend/app/repositories/user.py +++ b/apps/backend/app/repositories/user.py @@ -67,7 +67,7 @@ class UserRepository: .values( password_hash=password_hash, must_change_password=must_change_password, - credentials_changed_at=func.now(), + credentials_changed_at=func.clock_timestamp(), ) ) @@ -86,12 +86,12 @@ class UserRepository: await self._session.execute( update(AppUser) .where(AppUser.id == user_id) - .values(role=role.value, credentials_changed_at=func.now()) + .values(role=role.value, credentials_changed_at=func.clock_timestamp()) ) async def set_active(self, user_id: UUID, *, is_active: bool) -> None: await self._session.execute( update(AppUser) .where(AppUser.id == user_id) - .values(is_active=is_active, credentials_changed_at=func.now()) + .values(is_active=is_active, credentials_changed_at=func.clock_timestamp()) ) diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py new file mode 100644 index 0000000..522b4c5 --- /dev/null +++ b/apps/backend/app/schemas/auth.py @@ -0,0 +1,44 @@ +# Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix +# mégaoctets ferait travailler Argon2 gratuitement, à la charge du serveur. + +from typing import Literal, Self +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +from app.core.principal import Principal +from app.core.roles import AccountKind, Role + +PASSWORD_MIN_LENGTH = 12 +PASSWORD_MAX_LENGTH = 128 + + +class LoginRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH) + + +class PasswordChangeRequest(BaseModel): + current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH) + new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + + +class PrincipalResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + email: str + role: Role + kind: AccountKind + must_change_password: bool + + @classmethod + def from_principal(cls, principal: Principal) -> Self: + return cls.model_validate(principal) + + +class TokenResponse(BaseModel): + access_token: str + token_type: Literal["bearer"] = "bearer" # noqa: S105 + expires_in: int + principal: PrincipalResponse diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py new file mode 100644 index 0000000..19a4e26 --- /dev/null +++ b/apps/backend/app/services/auth.py @@ -0,0 +1,171 @@ +# Piège : les compteurs de limitation sont lus AVANT le hachage Argon2. Dans l'autre ordre, +# chaque requête rejetée coûterait quand même 17 ms de processeur et 19 Mio de mémoire, et la +# protection deviendrait l'amplificateur de déni de service qu'elle est censée empêcher. +# Piège : quand l'email est inconnu, `verify_dummy()` consomme le même temps qu'une +# vérification réelle. Sans lui, l'écart de temps de réponse est un oracle d'existence. +# Piège : la tentative échouée est validée en base AVANT que l'erreur ne soit levée. +# `get_session()` ne valide pas de lui-même, donc la preuve disparaîtrait avec la transaction. + +from dataclasses import dataclass +from typing import NoReturn, Protocol +from uuid import UUID + +from app.core.hashing import Argon2Hasher +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.core.security import TokenPolicy, encode_access_token +from app.models.audit_log import AuditAction +from app.models.login_attempt import LoginOutcome +from app.repositories.audit_log import AuditLogRepository +from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.user import UserRepository + + +class Transaction(Protocol): + async def commit(self) -> None: ... + + +class AuthError(Exception): + pass + + +class InvalidCredentialsError(AuthError): + pass + + +class RateLimitedError(AuthError): + def __init__(self, retry_after: int) -> None: + super().__init__("Trop de tentatives") + self.retry_after = retry_after + + +@dataclass(frozen=True, slots=True) +class LoginPolicy: + window_seconds: int + max_failures_per_identifier_and_ip: int + max_failures_per_ip: int + max_failures_per_identifier: int + + +@dataclass(frozen=True, slots=True) +class AuthenticatedSession: + principal: Principal + access_token: str + expires_in: int + + +class AuthService: + def __init__( + self, + *, + users: UserRepository, + attempts: LoginAttemptRepository, + audit: AuditLogRepository, + hasher: Argon2Hasher, + transaction: Transaction, + token_policy: TokenPolicy, + login_policy: LoginPolicy, + ) -> None: + self._users = users + self._attempts = attempts + self._audit = audit + self._hasher = hasher + self._transaction = transaction + self._token_policy = token_policy + self._login_policy = login_policy + + async def authenticate( + self, *, email: str, password: str, client_ip: str | None, user_agent: str | None + ) -> AuthenticatedSession: + await self._refuse_si_limite(email=email, client_ip=client_ip, user_agent=user_agent) + + compte = await self._users.get_by_email(email) + if compte is None: + await self._hasher.verify_dummy() + await self._echoue(email, client_ip, LoginOutcome.IDENTIFIANTS_INVALIDES) + + if not await self._hasher.verify(compte.password_hash, password): + await self._echoue( + email, client_ip, LoginOutcome.IDENTIFIANTS_INVALIDES, user_id=compte.id + ) + + if not compte.is_active or compte.kind != AccountKind.HUMAIN.value: + await self._echoue( + email, client_ip, LoginOutcome.COMPTE_INDISPONIBLE, user_id=compte.id + ) + + if self._hasher.needs_rehash(compte.password_hash): + await self._users.rehash_password(compte.id, await self._hasher.hash(password)) + + await self._users.touch_last_login(compte.id) + await self._attempts.record( + email=email, client_ip=client_ip, outcome=LoginOutcome.SUCCES, user_id=compte.id + ) + await self._transaction.commit() + + return self.issue_access_token( + Principal( + id=compte.id, + email=compte.email, + role=Role(compte.role), + kind=AccountKind(compte.kind), + must_change_password=compte.must_change_password, + ) + ) + + def issue_access_token(self, principal: Principal) -> AuthenticatedSession: + jeton = encode_access_token( + self._token_policy, + subject=principal.id, + role=principal.role.value, + kind=principal.kind.value, + ) + return AuthenticatedSession( + principal=principal, + access_token=jeton, + expires_in=int(self._token_policy.access_ttl.total_seconds()), + ) + + async def _refuse_si_limite( + self, *, email: str, client_ip: str | None, user_agent: str | None + ) -> None: + politique = self._login_policy + compteurs = await self._attempts.count_recent_failures( + email=email, client_ip=client_ip, window_seconds=politique.window_seconds + ) + + depasse = ( + compteurs.per_identifier_and_ip >= politique.max_failures_per_identifier_and_ip + or compteurs.per_ip >= politique.max_failures_per_ip + or compteurs.per_identifier >= politique.max_failures_per_identifier + ) + if not depasse: + return + + await self._attempts.record(email=email, client_ip=client_ip, outcome=LoginOutcome.LIMITE) + # Un blocage déclenché par l'identifiant seul signe une attaque distribuée : lui seul + # mérite une trace durable, les échecs ordinaires restent dans `login_attempt`. + if compteurs.per_identifier >= politique.max_failures_per_identifier: + await self._audit.record( + action=AuditAction.LIMITE_PAR_IDENTIFIANT, + actor_label=email.strip().lower(), + client_ip=client_ip, + user_agent=user_agent, + detail={"motif": "seuil par identifiant depasse"}, + ) + await self._transaction.commit() + raise RateLimitedError(politique.window_seconds) + + async def _echoue( + self, + email: str, + client_ip: str | None, + outcome: LoginOutcome, + *, + user_id: UUID | None = None, + ) -> NoReturn: + await self._attempts.record( + email=email, client_ip=client_ip, outcome=outcome, user_id=user_id + ) + await self._transaction.commit() + raise InvalidCredentialsError("Identifiants invalides") diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 684524d..f0e4f22 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.14,<3.15" dependencies = [ "fastapi>=0.141.1", "uvicorn[standard]>=0.53.0", - "pydantic>=2.13.5", + "pydantic[email]>=2.13.5", "pydantic-settings>=2.15.0", "sqlalchemy[asyncio]>=2.0.52", "asyncpg>=0.31.0", diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py new file mode 100644 index 0000000..08ceddd --- /dev/null +++ b/apps/backend/tests/api/test_auth.py @@ -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 diff --git a/apps/backend/tests/api/test_authorization.py b/apps/backend/tests/api/test_authorization.py new file mode 100644 index 0000000..05c9e28 --- /dev/null +++ b/apps/backend/tests/api/test_authorization.py @@ -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) diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py new file mode 100644 index 0000000..bfdd2fe --- /dev/null +++ b/apps/backend/tests/api/test_route_protection.py @@ -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 diff --git a/apps/backend/tests/repositories/test_audit_log.py b/apps/backend/tests/repositories/test_audit_log.py new file mode 100644 index 0000000..1c6fc64 --- /dev/null +++ b/apps/backend/tests/repositories/test_audit_log.py @@ -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() diff --git a/apps/backend/tests/repositories/test_login_attempt.py b/apps/backend/tests/repositories/test_login_attempt.py new file mode 100644 index 0000000..7ac620a --- /dev/null +++ b/apps/backend/tests/repositories/test_login_attempt.py @@ -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 diff --git a/apps/backend/tests/repositories/test_user.py b/apps/backend/tests/repositories/test_user.py new file mode 100644 index 0000000..0701a2d --- /dev/null +++ b/apps/backend/tests/repositories/test_user.py @@ -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 diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py new file mode 100644 index 0000000..3b8902f --- /dev/null +++ b/apps/backend/tests/services/test_auth.py @@ -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 diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py new file mode 100644 index 0000000..d8465b5 --- /dev/null +++ b/apps/backend/tests/test_cli.py @@ -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) diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index edc09c2..7c2b8f4 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -279,6 +279,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "enervision-backend" version = "0.1.0" @@ -290,7 +312,7 @@ dependencies = [ { name = "asyncpg" }, { name = "fastapi" }, { name = "prometheus-fastapi-instrumentator" }, - { name = "pydantic" }, + { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "python-json-logger" }, @@ -316,7 +338,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.31.0" }, { name = "fastapi", specifier = ">=0.141.1" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" }, - { name = "pydantic", specifier = ">=2.13.5" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.13.5" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "pyjwt", specifier = ">=2.10" }, { name = "python-json-logger", specifier = ">=4.2.0" }, @@ -646,6 +668,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.46.5" From 1f6210698df331fc9b3237eb56add56d16501989 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:49:30 +0200 Subject: [PATCH 06/13] =?UTF-8?q?feat(backend):=20fait=20tourner=20les=20j?= =?UTF-8?q?etons=20de=20rafra=C3=AEchissement=20et=20d=C3=A9tecte=20leur?= =?UTF-8?q?=20r=C3=A9utilisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le jeton de rafraîchissement est une chaîne opaque de 256 bits, jamais un JWT. Il doit être révocable, donc sa ligne en base existe de toute façon, et le JWT n'ajouterait qu'un second chemin de signature. Surtout, la séparation d'avec le jeton d'accès devient structurelle : un JWT ne figure dans aucune ligne, une chaîne opaque échoue au décodage. La confusion refresh-vers-accès, qui transforme une fenêtre de 15 minutes en fenêtre de 7 jours, est impossible même si quelqu'un oublie le test. Seule l'empreinte SHA-256 est stockée. Pas d'Argon2 : l'entrée fait 256 bits de CSPRNG, aucun dictionnaire ne l'atteint, et une KDF coûterait 17 ms à chaque rafraîchissement. La rotation ne protège de rien par elle-même : elle rend la réutilisation détectable, et c'est la détection qui termine le vol. Un jeton déjà tourné révoque donc toute sa famille et laisse une trace dans `audit_log` ; un jeton expiré, lui, ne révoque rien, ce n'est pas une preuve de compromission. Les deux cas ont leur test. La revendication est une seule instruction SQL avec RETURNING. Un SELECT puis un UPDATE laisseraient une fenêtre où deux onglets réussissent la même rotation ; le test d'intégration le prouve, ce qui est indémontrable sur un double. `expires_at` est absolu et hérité du prédécesseur : s'il glissait, la promesse de sept jours serait fictive. Corrige au passage un défaut trouvé par un test : une `HTTPException` construit sa propre réponse, donc l'effacement du cookie posé sur la `Response` injectée était perdu. Un navigateur gardait un cookie mort après une détection de réutilisation. --- ...821f71be74c0_jetons_de_rafraichissement.py | 77 +++++ apps/backend/app/api/deps.py | 3 + apps/backend/app/api/v1/endpoints/auth.py | 131 +++++++- apps/backend/app/core/cookies.py | 7 + apps/backend/app/models/__init__.py | 3 +- apps/backend/app/models/refresh_token.py | 64 ++++ .../backend/app/repositories/refresh_token.py | 106 +++++++ apps/backend/app/services/auth.py | 139 ++++++++- apps/backend/pyproject.toml | 4 +- apps/backend/tests/api/test_auth.py | 104 ++++++- .../tests/api/test_route_protection.py | 2 + .../tests/repositories/test_refresh_token.py | 190 ++++++++++++ apps/backend/tests/services/test_auth.py | 286 +++++++++++++++--- 13 files changed, 1047 insertions(+), 69 deletions(-) create mode 100644 apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py create mode 100644 apps/backend/app/models/refresh_token.py create mode 100644 apps/backend/app/repositories/refresh_token.py create mode 100644 apps/backend/tests/repositories/test_refresh_token.py diff --git a/apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py b/apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py new file mode 100644 index 0000000..15fb453 --- /dev/null +++ b/apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py @@ -0,0 +1,77 @@ +"""jetons de rafraichissement + +Revision ID: 821f71be74c0 +Revises: 517053a3c044 +Create Date: 2026-09-15 14:42:09.757949 + +Le jeton lui-meme n'est jamais stocke : seule son empreinte SHA-256 l'est, dans +`token_hash`. Un pg_dump qui fuiterait ne livrerait donc aucune session utilisable. + +L'index partiel `ix_refresh_token_vivants` sert la revocation en cascade et la +recherche des sessions actives, qui ne regardent jamais les lignes deja tournees. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "821f71be74c0" +down_revision: str | Sequence[str] | None = "517053a3c044" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +MOTIFS = "'logout', 'rotation', 'reuse_detected', 'password_change', 'admin'" +JETONS_VIVANTS = "revoked_at is null and rotated_at is null" + + +def upgrade() -> None: + op.create_table( + "refresh_token", + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.Column("family_id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("token_hash", sa.LargeBinary(), nullable=False), + sa.Column( + "issued_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("rotated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_reason", sa.Text(), nullable=True), + sa.Column("replaced_by", sa.UUID(), nullable=True), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.CheckConstraint( + f"revoked_reason is null or revoked_reason in ({MOTIFS})", + name="ck_refresh_token_revoked_reason", + ), + sa.ForeignKeyConstraint( + ["user_id"], ["app_user.id"], name="fk_refresh_token_user", ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name="pk_refresh_token"), + sa.UniqueConstraint("token_hash", name="uq_refresh_token_hash"), + ) + op.create_index("ix_refresh_token_family", "refresh_token", ["family_id"]) + op.create_index("ix_refresh_token_user", "refresh_token", ["user_id"]) + op.create_index( + "ix_refresh_token_vivants", + "refresh_token", + ["user_id"], + postgresql_where=JETONS_VIVANTS, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_refresh_token_vivants", table_name="refresh_token", postgresql_where=JETONS_VIVANTS + ) + op.drop_index("ix_refresh_token_user", table_name="refresh_token") + op.drop_index("ix_refresh_token_family", table_name="refresh_token") + op.drop_table("refresh_token") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index a35d628..82dc357 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -23,6 +23,7 @@ from app.core.security import decode_access_token as decode_token from app.db.session import get_session from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository from app.services.auth import AuthService, LoginPolicy @@ -95,6 +96,7 @@ def get_auth_service( return AuthService( users=UserRepository(session), attempts=LoginAttemptRepository(session), + refresh_tokens=RefreshTokenRepository(session), audit=AuditLogRepository(session), hasher=hasher, transaction=session, @@ -105,6 +107,7 @@ def get_auth_service( max_failures_per_ip=settings.login_max_failures_per_ip, max_failures_per_identifier=settings.login_max_failures_per_identifier, ), + refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds), ) diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 0def0d4..4d57559 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -1,14 +1,59 @@ +# Piège : le jeton de rafraîchissement ne quitte jamais le cookie httpOnly, et le jeton +# d'accès ne va jamais dans un cookie. C'est ce qui réduit la surface CSRF aux trois routes de +# ce module : partout ailleurs, le navigateur n'attache rien de lui-même. + from fastapi import APIRouter, Depends, HTTPException, Request, Response, status -from app.api.deps import AuthServiceDep, CurrentPrincipalDep, get_client_ip +from app.api.deps import ( + AuthServiceDep, + CurrentPrincipalDep, + SettingsDep, + get_client_ip, + require_trusted_origin, +) +from app.core.cookies import RefreshCookie, cookie_name from app.core.logging import get_logger from app.schemas.auth import LoginRequest, PrincipalResponse, TokenResponse -from app.services.auth import InvalidCredentialsError, RateLimitedError +from app.services.auth import ( + AuthenticatedSession, + InvalidCredentialsError, + RateLimitedError, + SessionRejectedError, +) router = APIRouter() logger = get_logger(__name__) DETAIL_IDENTIFIANTS = "Identifiants invalides" +DETAIL_SESSION = "Session invalide" + + +def repond( + response: Response, settings: SettingsDep, session: AuthenticatedSession +) -> TokenResponse: + response.headers["Cache-Control"] = "no-store" + response.set_cookie(**RefreshCookie.build(settings, session.refresh_secret).as_kwargs()) + return TokenResponse( + access_token=session.access_token, + expires_in=session.expires_in, + principal=PrincipalResponse.from_principal(session.principal), + ) + + +# Piège : une `HTTPException` construit sa propre réponse, donc tout en-tête posé sur la +# `Response` injectée est perdu. L'effacement du cookie doit voyager avec l'exception, +# sans quoi un navigateur garderait un cookie mort après une détection de réutilisation. +def entete_de_suppression(settings: SettingsDep) -> str: + temoin = Response() + temoin.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) + return temoin.headers["set-cookie"] + + +def lit_le_cookie(request: Request, settings: SettingsDep) -> str: + secret = request.cookies.get(cookie_name(settings)) + if not secret: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_SESSION) + return secret @router.post("/login", response_model=TokenResponse, summary="Ouvre une session") @@ -16,19 +61,16 @@ async def login( payload: LoginRequest, request: Request, response: Response, + settings: SettingsDep, service: AuthServiceDep, client_ip: str | None = Depends(get_client_ip), ) -> TokenResponse: - # Une réponse d'authentification ne doit jamais être conservée par un intermédiaire. response.headers["Cache-Control"] = "no-store" agent = request.headers.get("user-agent") try: session = await service.authenticate( - email=payload.email, - password=payload.password, - client_ip=client_ip, - user_agent=agent, + email=payload.email, password=payload.password, client_ip=client_ip, user_agent=agent ) except RateLimitedError as erreur: logger.warning("auth.rate_limited email=%s ip=%s", payload.email, client_ip) @@ -44,11 +86,76 @@ async def login( ) from erreur logger.info("auth.login.success user_id=%s ip=%s", session.principal.id, client_ip) - return TokenResponse( - access_token=session.access_token, - expires_in=session.expires_in, - principal=PrincipalResponse.from_principal(session.principal), - ) + return repond(response, settings, session) + + +@router.post( + "/refresh", + response_model=TokenResponse, + summary="Fait tourner la session", + dependencies=[Depends(require_trusted_origin)], +) +async def refresh( + request: Request, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> TokenResponse: + response.headers["Cache-Control"] = "no-store" + + try: + session = await service.refresh( + secret=lit_le_cookie(request, settings), + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except SessionRejectedError as erreur: + logger.warning("auth.refresh.rejected ip=%s", client_ip) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=DETAIL_SESSION, + headers={ + "Set-Cookie": entete_de_suppression(settings), + "Cache-Control": "no-store", + }, + ) from erreur + + return repond(response, settings, session) + + +@router.post( + "/logout", + status_code=status.HTTP_204_NO_CONTENT, + summary="Ferme la session courante", + dependencies=[Depends(require_trusted_origin)], +) +async def logout( + request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep +) -> None: + response.headers["Cache-Control"] = "no-store" + secret = request.cookies.get(cookie_name(settings)) + if secret: + await service.logout(secret=secret) + response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) + + +@router.post( + "/logout-all", + status_code=status.HTTP_204_NO_CONTENT, + summary="Ferme toutes les sessions du compte", + dependencies=[Depends(require_trusted_origin)], +) +async def logout_all( + principal: CurrentPrincipalDep, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, +) -> None: + response.headers["Cache-Control"] = "no-store" + revoquees = await service.logout_all(principal) + logger.info("auth.logout_all user_id=%s sessions=%s", principal.id, revoquees) + response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) @router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté") diff --git a/apps/backend/app/core/cookies.py b/apps/backend/app/core/cookies.py index f54dbff..1221085 100644 --- a/apps/backend/app/core/cookies.py +++ b/apps/backend/app/core/cookies.py @@ -47,6 +47,13 @@ class RefreshCookie: def as_kwargs(self) -> dict[str, Any]: return asdict(self) + def as_deletion_kwargs(self) -> dict[str, Any]: + # `Response.delete_cookie()` n'accepte ni `value` ni `max_age`, mais il exige le même + # nom, le même chemin et les mêmes attributs, sinon le navigateur garde le cookie. + arguments = asdict(self) + del arguments["value"], arguments["max_age"] + return arguments + def cookie_name(settings: Settings) -> str: if settings.cookies_are_secure: diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 222295d..9e65265 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -3,6 +3,7 @@ from app.models.audit_log import AuditLog from app.models.login_attempt import LoginAttempt +from app.models.refresh_token import RefreshToken from app.models.user import AppUser -__all__ = ["AppUser", "AuditLog", "LoginAttempt"] +__all__ = ["AppUser", "AuditLog", "LoginAttempt", "RefreshToken"] diff --git a/apps/backend/app/models/refresh_token.py b/apps/backend/app/models/refresh_token.py new file mode 100644 index 0000000..8153776 --- /dev/null +++ b/apps/backend/app/models/refresh_token.py @@ -0,0 +1,64 @@ +# Pourquoi : un jeton de rafraîchissement est une chaîne opaque, jamais un JWT. Il doit être +# révocable, donc cette ligne existe de toute façon ; le JWT n'ajouterait qu'un second chemin de +# signature. Surtout, la séparation devient structurelle : un JWT ne figure dans aucune ligne, +# une chaîne opaque échoue au décodage. Aucune confusion de type n'est possible. +# Piège : `expires_at` est absolu et hérité du prédécesseur à chaque rotation. S'il glissait, +# la promesse de sept jours serait fictive et une session active ne finirait jamais. + +import uuid +from datetime import datetime +from enum import StrEnum + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, LargeBinary, Text, func +from sqlalchemy.dialects.postgresql import INET +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class RevocationReason(StrEnum): + DECONNEXION = "logout" + ROTATION = "rotation" + REUTILISATION = "reuse_detected" + CHANGEMENT_MOT_DE_PASSE = "password_change" + ADMINISTRATION = "admin" + + +MOTIFS_AUTORISES = ", ".join(f"'{motif.value}'" for motif in RevocationReason) + + +class RefreshToken(Base): + __tablename__ = "refresh_token" + __table_args__ = ( + CheckConstraint( + f"revoked_reason is null or revoked_reason in ({MOTIFS_AUTORISES})", + name="ck_refresh_token_revoked_reason", + ), + Index("ix_refresh_token_family", "family_id"), + Index("ix_refresh_token_user", "user_id"), + Index( + "ix_refresh_token_vivants", + "user_id", + postgresql_where="revoked_at is null and rotated_at is null", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid() + ) + family_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False) + user_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False + ) + token_hash: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, unique=True) + issued_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + rotated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + replaced_by: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/apps/backend/app/repositories/refresh_token.py b/apps/backend/app/repositories/refresh_token.py new file mode 100644 index 0000000..08d7980 --- /dev/null +++ b/apps/backend/app/repositories/refresh_token.py @@ -0,0 +1,106 @@ +# Piège : `claim_for_rotation()` est une seule instruction. Un SELECT puis un UPDATE +# laisseraient une fenêtre où deux onglets réussissent la même rotation. Zéro ligne retournée +# signifie donc, sans ambiguïté, que le jeton était déjà tourné, révoqué, expiré ou inconnu, et +# c'est `inspect()` qui départage ensuite ces cas. + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.refresh_token import RefreshToken, RevocationReason + + +@dataclass(frozen=True, slots=True) +class ClaimedToken: + id: UUID + family_id: UUID + user_id: UUID + expires_at: datetime + + +class RefreshTokenRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create( + self, + *, + user_id: UUID, + family_id: UUID, + token_hash: bytes, + expires_at: datetime, + client_ip: str | None, + user_agent: str | None, + ) -> RefreshToken: + jeton = RefreshToken( + user_id=user_id, + family_id=family_id, + token_hash=token_hash, + expires_at=expires_at, + client_ip=client_ip, + user_agent=user_agent, + ) + self._session.add(jeton) + await self._session.flush() + return jeton + + async def claim_for_rotation(self, token_hash: bytes) -> ClaimedToken | None: + requete = ( + update(RefreshToken) + .where( + RefreshToken.token_hash == token_hash, + RefreshToken.rotated_at.is_(None), + RefreshToken.revoked_at.is_(None), + RefreshToken.expires_at > func.clock_timestamp(), + ) + .values( + rotated_at=func.clock_timestamp(), + revoked_at=func.clock_timestamp(), + revoked_reason=RevocationReason.ROTATION.value, + ) + .returning( + RefreshToken.id, + RefreshToken.family_id, + RefreshToken.user_id, + RefreshToken.expires_at, + ) + ) + ligne = (await self._session.execute(requete)).one_or_none() + if ligne is None: + return None + return ClaimedToken( + id=ligne.id, + family_id=ligne.family_id, + user_id=ligne.user_id, + expires_at=ligne.expires_at, + ) + + async def inspect(self, token_hash: bytes) -> RefreshToken | None: + requete = select(RefreshToken).where(RefreshToken.token_hash == token_hash) + return (await self._session.execute(requete)).scalar_one_or_none() + + async def link_replacement(self, ancien_id: UUID, nouveau_id: UUID) -> None: + await self._session.execute( + update(RefreshToken).where(RefreshToken.id == ancien_id).values(replaced_by=nouveau_id) + ) + + async def revoke_family(self, family_id: UUID, reason: RevocationReason) -> int: + resultat = await self._session.execute( + update(RefreshToken) + .where(RefreshToken.family_id == family_id, RefreshToken.revoked_at.is_(None)) + .values(revoked_at=func.clock_timestamp(), revoked_reason=reason.value) + .returning(RefreshToken.id) + ) + return len(resultat.all()) + + async def revoke_all_for_user(self, user_id: UUID, reason: RevocationReason) -> int: + resultat = await self._session.execute( + update(RefreshToken) + .where(RefreshToken.user_id == user_id, RefreshToken.revoked_at.is_(None)) + .values(revoked_at=func.clock_timestamp(), revoked_reason=reason.value) + .returning(RefreshToken.id) + ) + return len(resultat.all()) diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index 19a4e26..4e15dbd 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -5,19 +5,30 @@ # vérification réelle. Sans lui, l'écart de temps de réponse est un oracle d'existence. # Piège : la tentative échouée est validée en base AVANT que l'erreur ne soit levée. # `get_session()` ne valide pas de lui-même, donc la preuve disparaîtrait avec la transaction. +# Piège : dans `refresh()`, un jeton expiré ne révoque PAS la famille, un jeton déjà tourné si. +# La rotation ne protège de rien par elle-même : elle rend la réutilisation détectable, et +# c'est la détection qui termine le vol. from dataclasses import dataclass +from datetime import UTC, datetime, timedelta from typing import NoReturn, Protocol -from uuid import UUID +from uuid import UUID, uuid4 from app.core.hashing import Argon2Hasher from app.core.principal import Principal from app.core.roles import AccountKind, Role -from app.core.security import TokenPolicy, encode_access_token -from app.models.audit_log import AuditAction +from app.core.security import ( + TokenPolicy, + encode_access_token, + fingerprint_refresh, + generate_refresh_secret, +) +from app.models.audit_log import AuditAction, AuditOutcome from app.models.login_attempt import LoginOutcome +from app.models.refresh_token import RevocationReason from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository @@ -33,6 +44,10 @@ class InvalidCredentialsError(AuthError): pass +class SessionRejectedError(AuthError): + pass + + class RateLimitedError(AuthError): def __init__(self, retry_after: int) -> None: super().__init__("Trop de tentatives") @@ -52,6 +67,7 @@ class AuthenticatedSession: principal: Principal access_token: str expires_in: int + refresh_secret: str class AuthService: @@ -60,19 +76,23 @@ class AuthService: *, users: UserRepository, attempts: LoginAttemptRepository, + refresh_tokens: RefreshTokenRepository, audit: AuditLogRepository, hasher: Argon2Hasher, transaction: Transaction, token_policy: TokenPolicy, login_policy: LoginPolicy, + refresh_ttl: timedelta, ) -> None: self._users = users self._attempts = attempts + self._refresh = refresh_tokens self._audit = audit self._hasher = hasher self._transaction = transaction self._token_policy = token_policy self._login_policy = login_policy + self._refresh_ttl = refresh_ttl async def authenticate( self, *, email: str, password: str, client_ip: str | None, user_agent: str | None @@ -101,19 +121,60 @@ class AuthService: await self._attempts.record( email=email, client_ip=client_ip, outcome=LoginOutcome.SUCCES, user_id=compte.id ) + secret = await self._ouvre_une_famille( + user_id=compte.id, client_ip=client_ip, user_agent=user_agent + ) await self._transaction.commit() - return self.issue_access_token( - Principal( - id=compte.id, - email=compte.email, - role=Role(compte.role), - kind=AccountKind(compte.kind), - must_change_password=compte.must_change_password, - ) - ) + return self._session(self._en_principal(compte), secret) - def issue_access_token(self, principal: Principal) -> AuthenticatedSession: + async def refresh( + self, *, secret: str, client_ip: str | None, user_agent: str | None + ) -> AuthenticatedSession: + empreinte = fingerprint_refresh(secret) + revendique = await self._refresh.claim_for_rotation(empreinte) + if revendique is None: + await self._traite_rotation_refusee(empreinte, client_ip, user_agent) + + compte = await self._users.get_by_id(revendique.user_id) + if compte is None or not compte.is_active: + await self._refresh.revoke_family(revendique.family_id, RevocationReason.ADMINISTRATION) + await self._transaction.commit() + raise SessionRejectedError("Session révoquée") + + nouveau_secret = generate_refresh_secret() + nouveau = await self._refresh.create( + user_id=revendique.user_id, + family_id=revendique.family_id, + token_hash=fingerprint_refresh(nouveau_secret), + expires_at=revendique.expires_at, + client_ip=client_ip, + user_agent=user_agent, + ) + await self._refresh.link_replacement(revendique.id, nouveau.id) + await self._transaction.commit() + + return self._session(self._en_principal(compte), nouveau_secret) + + async def logout(self, *, secret: str) -> None: + ligne = await self._refresh.inspect(fingerprint_refresh(secret)) + if ligne is not None: + await self._refresh.revoke_family(ligne.family_id, RevocationReason.DECONNEXION) + await self._transaction.commit() + + async def logout_all(self, principal: Principal) -> int: + revoquees = await self._refresh.revoke_all_for_user( + principal.id, RevocationReason.DECONNEXION + ) + await self._audit.record( + action=AuditAction.SESSIONS_REVOQUEES, + actor=principal, + detail={"sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + return revoquees + + def _session(self, principal: Principal, refresh_secret: str) -> AuthenticatedSession: jeton = encode_access_token( self._token_policy, subject=principal.id, @@ -124,8 +185,59 @@ class AuthService: principal=principal, access_token=jeton, expires_in=int(self._token_policy.access_ttl.total_seconds()), + refresh_secret=refresh_secret, ) + def _en_principal(self, compte: object) -> Principal: + return Principal( + id=compte.id, # type: ignore[attr-defined] + email=compte.email, # type: ignore[attr-defined] + role=Role(compte.role), # type: ignore[attr-defined] + kind=AccountKind(compte.kind), # type: ignore[attr-defined] + must_change_password=compte.must_change_password, # type: ignore[attr-defined] + ) + + async def _ouvre_une_famille( + self, *, user_id: UUID, client_ip: str | None, user_agent: str | None + ) -> str: + secret = generate_refresh_secret() + await self._refresh.create( + user_id=user_id, + family_id=uuid4(), + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + self._refresh_ttl, + client_ip=client_ip, + user_agent=user_agent, + ) + return secret + + async def _traite_rotation_refusee( + self, empreinte: bytes, client_ip: str | None, user_agent: str | None + ) -> NoReturn: + ligne = await self._refresh.inspect(empreinte) + if ligne is None: + raise SessionRejectedError("Session inconnue") + + if ligne.expires_at <= datetime.now(UTC): + raise SessionRejectedError("Session expirée") + + # Présenter un jeton déjà tourné est une preuve de compromission, pas un accident : toute + # la famille tombe, y compris la session encore vivante du voleur ou de la victime. + revoquees = await self._refresh.revoke_family( + ligne.family_id, RevocationReason.REUTILISATION + ) + await self._audit.record( + action=AuditAction.REFRESH_REUTILISE, + outcome=AuditOutcome.ECHEC, + target_type="refresh_token", + target_id=str(ligne.family_id), + client_ip=client_ip, + user_agent=user_agent, + detail={"famille": str(ligne.family_id), "sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + raise SessionRejectedError("Session révoquée") + async def _refuse_si_limite( self, *, email: str, client_ip: str | None, user_agent: str | None ) -> None: @@ -148,6 +260,7 @@ class AuthService: if compteurs.per_identifier >= politique.max_failures_per_identifier: await self._audit.record( action=AuditAction.LIMITE_PAR_IDENTIFIANT, + outcome=AuditOutcome.ECHEC, actor_label=email.strip().lower(), client_ip=client_ip, user_agent=user_agent, diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index f0e4f22..18bf979 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -60,8 +60,8 @@ select = [ ignore = ["B008"] [tool.ruff.lint.per-file-ignores] -# S105 et S106 signalent les secrets en dur, ce qui est justement la matière des tests d'auth. -"tests/**/*.py" = ["S101", "S105", "S106"] +# S105 à S107 signalent les secrets en dur, qui sont justement la matière des tests d'auth. +"tests/**/*.py" = ["S101", "S105", "S106", "S107"] [tool.ruff.lint.isort] known-first-party = ["app"] diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py index 08ceddd..1d734da 100644 --- a/apps/backend/tests/api/test_auth.py +++ b/apps/backend/tests/api/test_auth.py @@ -12,6 +12,7 @@ from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, RateLimitedError, + SessionRejectedError, ) IDENTIFIANTS = {"email": "operateur@enervision.fr", "password": "un-mot-de-passe-valide"} @@ -29,11 +30,20 @@ class FauxService: def __init__(self, erreur: Exception | None = None) -> None: self._erreur = erreur + async def refresh(self, **_: object) -> AuthenticatedSession: + return await self.authenticate() + + async def logout(self, **_: object) -> None: + return None + 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 + principal=PRINCIPAL, + access_token="un.jeton.factice", + expires_in=900, + refresh_secret="un-secret-opaque", ) @@ -104,3 +114,95 @@ async def test_login_rejects_a_malformed_body_without_echoing_the_password( assert response.status_code == 422 assert "un-mot-de-passe-valide" not in response.text assert "x" * 129 not in response.text + + +async def test_login_posts_an_http_only_refresh_cookie_scoped_to_the_auth_routes( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + depose = response.headers["set-cookie"] + assert depose.startswith("ev_refresh=un-secret-opaque") + assert "HttpOnly" in depose + assert "SameSite=strict" in depose + assert "Path=/api/v1/auth" in depose + + +async def test_login_keeps_the_refresh_secret_out_of_the_response_body( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + assert "un-secret-opaque" not in response.text + + +async def test_refresh_returns_401_when_no_cookie_is_presented( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/refresh") + + assert response.status_code == 401 + + +async def test_refresh_rotates_the_cookie_when_the_session_is_still_valid( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + client.cookies.set("ev_refresh", "un-secret-opaque") + + response = await client.post("/api/v1/auth/refresh") + + assert response.status_code == 200 + assert "ev_refresh=" in response.headers["set-cookie"] + + +async def test_refresh_clears_the_cookie_when_the_session_is_rejected( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = SessionRejectedError("Session révoquée") + client.cookies.set("ev_refresh", "un-secret-rejoue") + + response = await client.post("/api/v1/auth/refresh") + + assert response.status_code == 401 + assert 'ev_refresh=""' in response.headers["set-cookie"] + assert "Path=/api/v1/auth" in response.headers["set-cookie"] + + +async def test_logout_answers_204_and_clears_the_cookie( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + client.cookies.set("ev_refresh", "un-secret-opaque") + + response = await client.post("/api/v1/auth/logout") + + assert response.status_code == 204 + assert 'ev_refresh=""' in response.headers["set-cookie"] + + +async def test_logout_stays_idempotent_without_a_cookie( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/logout") + + assert response.status_code == 204 + + +@pytest.mark.parametrize( + "chemin", + ["/api/v1/auth/refresh", "/api/v1/auth/logout"], + ids=["rotation", "deconnexion"], +) +async def test_a_cookie_bearing_route_refuses_a_foreign_origin( + fake_auth_service: list[Exception | None], client: AsyncClient, chemin: str +) -> None: + response = await client.post(chemin, headers={"Origin": "https://malveillant.example"}) + + assert response.status_code == 403 + + +async def test_a_cookie_bearing_route_accepts_a_request_without_origin( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/logout") + + assert response.status_code != 403 diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py index bfdd2fe..9a04338 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -16,6 +16,8 @@ ROUTES_PUBLIQUES = frozenset( ("GET", "/api/v1/health/live"), ("GET", "/api/v1/health/ready"), ("POST", "/api/v1/auth/login"), + # Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. + ("POST", "/api/v1/auth/logout"), ("GET", "/metrics"), } ) diff --git a/apps/backend/tests/repositories/test_refresh_token.py b/apps/backend/tests/repositories/test_refresh_token.py new file mode 100644 index 0000000..73d82b4 --- /dev/null +++ b/apps/backend/tests/repositories/test_refresh_token.py @@ -0,0 +1,190 @@ +# Le premier test de ce fichier est le seul endroit où l'atomicité de la rotation se démontre : +# sur un double, deux appels concurrents réussiraient tous les deux. + +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.roles import Role +from app.core.security import fingerprint_refresh, generate_refresh_secret +from app.models.refresh_token import RevocationReason +from app.repositories.refresh_token import RefreshTokenRepository +from app.repositories.user import UserRepository + +pytestmark = pytest.mark.integration + +DUREE = timedelta(days=7) + + +async def un_compte(session: AsyncSession) -> uuid.UUID: + compte = await UserRepository(session).create( + email=f"jeton-{uuid.uuid4().hex[:12]}@enervision.fr", + password_hash="$argon2id$x", + role=Role.LECTEUR, + ) + return compte.id + + +async def un_jeton( + depot: RefreshTokenRepository, + user_id: uuid.UUID, + *, + family_id: uuid.UUID | None = None, + duree: timedelta = DUREE, +) -> tuple[str, uuid.UUID]: + secret = generate_refresh_secret() + jeton = await depot.create( + user_id=user_id, + family_id=family_id or uuid.uuid4(), + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + duree, + client_ip="203.0.113.10", + user_agent="pytest", + ) + return secret, jeton.family_id + + +async def test_claim_for_rotation_only_succeeds_once(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + secret, _ = await un_jeton(depot, await un_compte(session)) + + premier = await depot.claim_for_rotation(fingerprint_refresh(secret)) + second = await depot.claim_for_rotation(fingerprint_refresh(secret)) + await session.rollback() + + assert premier is not None + assert second is None + + +async def test_claim_for_rotation_refuses_an_expired_token(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + secret, _ = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1)) + + revendique = await depot.claim_for_rotation(fingerprint_refresh(secret)) + await session.rollback() + + assert revendique is None + + +async def test_claim_for_rotation_returns_nothing_for_an_unknown_fingerprint( + session: AsyncSession, +) -> None: + revendique = await RefreshTokenRepository(session).claim_for_rotation( + fingerprint_refresh(generate_refresh_secret()) + ) + + assert revendique is None + + +async def test_inspect_finds_a_token_that_rotation_already_refused( + session: AsyncSession, +) -> None: + depot = RefreshTokenRepository(session) + secret, _ = await un_jeton(depot, await un_compte(session)) + await depot.claim_for_rotation(fingerprint_refresh(secret)) + + ligne = await depot.inspect(fingerprint_refresh(secret)) + rotation, motif = (ligne.rotated_at, ligne.revoked_reason) if ligne else (None, None) + await session.rollback() + + assert rotation is not None + assert motif == RevocationReason.ROTATION.value + + +async def test_revoke_family_touches_every_living_token_of_that_family_only( + session: AsyncSession, +) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + famille = uuid.uuid4() + await un_jeton(depot, compte, family_id=famille) + await un_jeton(depot, compte, family_id=famille) + autre_secret, _ = await un_jeton(depot, compte) + + revoquees = await depot.revoke_family(famille, RevocationReason.REUTILISATION) + intacte = await depot.claim_for_rotation(fingerprint_refresh(autre_secret)) + await session.rollback() + + assert revoquees == 2 + assert intacte is not None + + +async def test_revoke_family_is_idempotent(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + famille = uuid.uuid4() + await un_jeton(depot, compte, family_id=famille) + + premier = await depot.revoke_family(famille, RevocationReason.DECONNEXION) + second = await depot.revoke_family(famille, RevocationReason.DECONNEXION) + await session.rollback() + + assert premier == 1 + assert second == 0 + + +async def test_revoke_all_for_user_closes_every_family_at_once(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + await un_jeton(depot, compte) + await un_jeton(depot, compte) + await un_jeton(depot, compte) + + revoquees = await depot.revoke_all_for_user(compte, RevocationReason.CHANGEMENT_MOT_DE_PASSE) + await session.rollback() + + assert revoquees == 3 + + +async def test_link_replacement_records_the_successor(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + ancien_secret, famille = await un_jeton(depot, compte) + revendique = await depot.claim_for_rotation(fingerprint_refresh(ancien_secret)) + assert revendique is not None + nouveau_secret = generate_refresh_secret() + nouveau = await depot.create( + user_id=compte, + family_id=famille, + token_hash=fingerprint_refresh(nouveau_secret), + expires_at=revendique.expires_at, + client_ip=None, + user_agent=None, + ) + + await depot.link_replacement(revendique.id, nouveau.id) + ligne = await depot.inspect(fingerprint_refresh(ancien_secret)) + successeur = ligne.replaced_by if ligne else None + await session.rollback() + + assert successeur == nouveau.id + + +async def test_the_database_refuses_two_tokens_sharing_a_fingerprint( + session: AsyncSession, +) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + secret = generate_refresh_secret() + await depot.create( + user_id=compte, + family_id=uuid.uuid4(), + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + DUREE, + client_ip=None, + user_agent=None, + ) + + with pytest.raises(IntegrityError): + await depot.create( + user_id=compte, + family_id=uuid.uuid4(), + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + DUREE, + client_ip=None, + user_agent=None, + ) + await session.rollback() diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 3b8902f..01697d8 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -6,14 +6,23 @@ from uuid import UUID, uuid4 import pytest -from app.core.security import TokenPolicy, decode_access_token +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.core.security import ( + TokenPolicy, + decode_access_token, + fingerprint_refresh, +) from app.models.login_attempt import LoginOutcome +from app.models.refresh_token import RevocationReason from app.repositories.login_attempt import FailureCounts +from app.repositories.refresh_token import ClaimedToken from app.services.auth import ( AuthService, InvalidCredentialsError, LoginPolicy, RateLimitedError, + SessionRejectedError, ) POLITIQUE_JETON = TokenPolicy( @@ -51,6 +60,9 @@ class FauxDepotComptes: async def get_by_email(self, email: str) -> FauxCompte | None: return self.compte + async def get_by_id(self, user_id: UUID) -> FauxCompte | None: + return self.compte + async def rehash_password(self, user_id: UUID, password_hash: str) -> None: self.rehachages += 1 @@ -78,6 +90,50 @@ class FauxDepotAudit: self.lignes.append((str(action), detail)) +@dataclass +class FauxJeton: + id: UUID = field(default_factory=uuid4) + family_id: UUID = field(default_factory=uuid4) + user_id: UUID = field(default_factory=uuid4) + expires_at: datetime = field(default_factory=lambda: datetime.now(UTC) + timedelta(days=7)) + rotated_at: datetime | None = None + revoked_at: datetime | None = None + + +class FauxDepotJetons: + def __init__( + self, revendique: ClaimedToken | None = None, connu: FauxJeton | None = None + ) -> None: + self.revendique = revendique + self.connu = connu + self.crees: list[UUID] = [] + self.familles_revoquees: list[tuple[UUID, str]] = [] + self.revocations_par_compte: list[tuple[UUID, str]] = [] + self.liaisons: list[tuple[UUID, UUID]] = [] + + async def create(self, *, user_id: UUID, family_id: UUID, **_: object) -> FauxJeton: + jeton = FauxJeton(user_id=user_id, family_id=family_id) + self.crees.append(jeton.id) + return jeton + + async def claim_for_rotation(self, token_hash: bytes) -> ClaimedToken | None: + return self.revendique + + async def inspect(self, token_hash: bytes) -> FauxJeton | None: + return self.connu + + async def link_replacement(self, ancien_id: UUID, nouveau_id: UUID) -> None: + self.liaisons.append((ancien_id, nouveau_id)) + + async def revoke_family(self, family_id: UUID, reason: RevocationReason) -> int: + self.familles_revoquees.append((family_id, reason.value)) + return 2 + + async def revoke_all_for_user(self, user_id: UUID, reason: RevocationReason) -> int: + self.revocations_par_compte.append((user_id, reason.value)) + return 3 + + class FauxHacheur: def __init__(self, *, accepte: bool = True, rehachage_requis: bool = False) -> None: self.verifications = 0 @@ -108,26 +164,40 @@ class FausseTransaction: self.validations += 1 +@dataclass +class Attirail: + service: AuthService + comptes: FauxDepotComptes + tentatives: FauxDepotTentatives + jetons: FauxDepotJetons + audit: FauxDepotAudit + hacheur: FauxHacheur + + def fabrique_service( *, compte: FauxCompte | None = None, compteurs: FailureCounts | None = None, hacheur: FauxHacheur | None = None, -) -> tuple[AuthService, FauxDepotComptes, FauxDepotTentatives, FauxDepotAudit, FauxHacheur]: + jetons: FauxDepotJetons | None = None, +) -> Attirail: comptes = FauxDepotComptes(compte) tentatives = FauxDepotTentatives(compteurs) + depot_jetons = jetons or FauxDepotJetons() audit = FauxDepotAudit() hacheur = hacheur or FauxHacheur() service = AuthService( users=comptes, # type: ignore[arg-type] attempts=tentatives, # type: ignore[arg-type] + refresh_tokens=depot_jetons, # 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, + refresh_ttl=timedelta(days=7), ) - return service, comptes, tentatives, audit, hacheur + return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur) async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object: @@ -139,64 +209,73 @@ async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-va ) +async def rafraichit(service: AuthService, secret: str = "un-secret-opaque") -> object: + return await service.refresh(secret=secret, 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) + attirail = fabrique_service(compte=compte) - session = await connecte(service) + session = await connecte(attirail.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 + assert attirail.tentatives.enregistrees == [LoginOutcome.SUCCES.value] + assert attirail.comptes.connexions_datees == 1 + + +async def test_authenticate_opens_one_refresh_family_per_login() -> None: + attirail = fabrique_service(compte=FauxCompte()) + + session = await connecte(attirail.service) + + assert len(attirail.jetons.crees) == 1 + assert session.refresh_secret # type: ignore[attr-defined] async def test_authenticate_verifies_a_decoy_digest_when_the_email_is_unknown() -> None: - service, _, tentatives, _, hacheur = fabrique_service(compte=None) + attirail = fabrique_service(compte=None) with pytest.raises(InvalidCredentialsError): - await connecte(service) + await connecte(attirail.service) - assert hacheur.verifications == 1 - assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] + assert attirail.hacheur.verifications == 1 + assert attirail.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 - ) + attirail = fabrique_service(compte=FauxCompte(), compteurs=compteurs) with pytest.raises(RateLimitedError): - await connecte(service) + await connecte(attirail.service) - assert hacheur.verifications == 0 - assert hacheur.hachages == 0 - assert tentatives.enregistrees == [LoginOutcome.LIMITE.value] - assert audit.lignes == [] + assert attirail.hacheur.verifications == 0 + assert attirail.hacheur.hachages == 0 + assert attirail.tentatives.enregistrees == [LoginOutcome.LIMITE.value] + assert attirail.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) + attirail = fabrique_service(compte=FauxCompte(), compteurs=compteurs) with pytest.raises(RateLimitedError): - await connecte(service) + await connecte(attirail.service) - assert len(audit.lignes) == 1 - assert "identifier_throttled" in audit.lignes[0][0] + assert len(attirail.audit.lignes) == 1 + assert "identifier_throttled" in attirail.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) - ) + attirail = fabrique_service(compte=FauxCompte(), hacheur=FauxHacheur(accepte=False)) with pytest.raises(InvalidCredentialsError): - await connecte(service) + await connecte(attirail.service) - assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] + assert attirail.tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] @pytest.mark.parametrize( @@ -207,28 +286,155 @@ async def test_authenticate_rejects_a_wrong_password_with_the_generic_error() -> async def test_authenticate_rejects_unavailable_accounts_after_checking_the_password( compte: FauxCompte, ) -> None: - service, _, tentatives, _, hacheur = fabrique_service(compte=compte) + attirail = fabrique_service(compte=compte) with pytest.raises(InvalidCredentialsError): - await connecte(service) + await connecte(attirail.service) - assert hacheur.verifications == 1 - assert tentatives.enregistrees == [LoginOutcome.COMPTE_INDISPONIBLE.value] + assert attirail.hacheur.verifications == 1 + assert attirail.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) - ) + attirail = fabrique_service(compte=FauxCompte(), hacheur=FauxHacheur(rehachage_requis=True)) - await connecte(service) + await connecte(attirail.service) - assert comptes.rehachages == 1 + assert attirail.comptes.rehachages == 1 async def test_authenticate_leaves_the_digest_alone_when_the_parameters_match() -> None: - service, comptes, _, _, _ = fabrique_service(compte=FauxCompte()) + attirail = fabrique_service(compte=FauxCompte()) - await connecte(service) + await connecte(attirail.service) - assert comptes.rehachages == 0 + assert attirail.comptes.rehachages == 0 + + +async def test_refresh_rotates_the_token_and_keeps_the_family() -> None: + compte = FauxCompte() + revendique = ClaimedToken( + id=uuid4(), + family_id=uuid4(), + user_id=compte.id, + expires_at=datetime.now(UTC) + timedelta(days=5), + ) + attirail = fabrique_service(compte=compte, jetons=FauxDepotJetons(revendique=revendique)) + + session = await rafraichit(attirail.service) + + assert session.refresh_secret # type: ignore[attr-defined] + assert len(attirail.jetons.crees) == 1 + assert attirail.jetons.liaisons == [(revendique.id, attirail.jetons.crees[0])] + assert attirail.jetons.familles_revoquees == [] + + +async def test_refresh_inherits_the_absolute_expiry_of_its_predecessor() -> None: + compte = FauxCompte() + echeance = datetime.now(UTC) + timedelta(days=2) + revendique = ClaimedToken(id=uuid4(), family_id=uuid4(), user_id=compte.id, expires_at=echeance) + attirail = fabrique_service(compte=compte, jetons=FauxDepotJetons(revendique=revendique)) + + await rafraichit(attirail.service) + + assert revendique.expires_at == echeance + + +async def test_refresh_rejects_an_unknown_secret_without_touching_any_family() -> None: + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons()) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [] + assert attirail.audit.lignes == [] + + +async def test_refresh_rejects_an_expired_token_without_revoking_its_family() -> None: + perime = FauxJeton(expires_at=datetime.now(UTC) - timedelta(minutes=1)) + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons(connu=perime)) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [] + assert attirail.audit.lignes == [] + + +async def test_refresh_revokes_the_whole_family_when_a_rotated_token_comes_back() -> None: + rejoue = FauxJeton(rotated_at=datetime.now(UTC), revoked_at=datetime.now(UTC)) + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons(connu=rejoue)) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [ + (rejoue.family_id, RevocationReason.REUTILISATION.value) + ] + assert "refresh_reuse_detected" in attirail.audit.lignes[0][0] + + +async def test_refresh_revokes_the_family_when_the_account_was_disabled_meanwhile() -> None: + compte = FauxCompte(is_active=False) + revendique = ClaimedToken( + id=uuid4(), + family_id=uuid4(), + user_id=compte.id, + expires_at=datetime.now(UTC) + timedelta(days=5), + ) + attirail = fabrique_service(compte=compte, jetons=FauxDepotJetons(revendique=revendique)) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [ + (revendique.family_id, RevocationReason.ADMINISTRATION.value) + ] + + +async def test_logout_revokes_only_the_presented_family() -> None: + connu = FauxJeton() + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons(connu=connu)) + + await attirail.service.logout(secret="un-secret-opaque") + + assert attirail.jetons.familles_revoquees == [ + (connu.family_id, RevocationReason.DECONNEXION.value) + ] + assert attirail.jetons.revocations_par_compte == [] + + +async def test_logout_stays_silent_when_the_cookie_points_at_nothing() -> None: + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons()) + + await attirail.service.logout(secret="un-secret-inconnu") + + assert attirail.jetons.familles_revoquees == [] + + +async def test_logout_all_revokes_every_session_and_leaves_an_audit_trail() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + acteur = Principal( + id=compte.id, + email=compte.email, + role=Role.OPERATEUR, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + revoquees = await attirail.service.logout_all(acteur) + + assert revoquees == 3 + assert attirail.jetons.revocations_par_compte == [ + (compte.id, RevocationReason.DECONNEXION.value) + ] + assert "all_sessions_revoked" in attirail.audit.lignes[0][0] + + +def test_fingerprint_is_what_the_service_stores_not_the_secret_itself() -> None: + secret = "un-secret-opaque" + + empreinte = fingerprint_refresh(secret) + + assert secret.encode() not in empreinte From cf9c707592196b23ebf367a42dc7b6911935ce15 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Tue, 15 Sep 2026 14:52:14 +0200 Subject: [PATCH 07/13] docs(docs): ajoute schema de donnees et sa description --- docs/architecture/40-data.md | 43 ++++++++++++++++++ .../images/EnerVision-schema-donnees.png | Bin 0 -> 152794 bytes 2 files changed, 43 insertions(+) create mode 100644 docs/architecture/images/EnerVision-schema-donnees.png diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 2d53844..fb8470c 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -141,3 +141,46 @@ Elles relèvent du jalon J2, « valider le périmètre retenu », et bloquent le - **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse. - **Quelles unités** sont manipulées, et si une même table les mélange. - **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures. + +## Modélisation détaillée des données + +Cette modélisation prend en compte les fichiers CSV historiques, +leurs métadonnées JSON et les données de l’API Mock. +Elle comprend six tables, depuis le stockage des mesures +jusqu’aux recommandations proposées à l’utilisateur. + +### Schéma de données + +Le diagramme ci-dessous présente les tables et leurs relations. +Il décrit une structure de conception ; les migrations correspondantes +restent à implémenter. + +![Schéma de données EnerVision](images/EnerVision-schema-donnees.png) + +*Figure — Modélisation des données EnerVision.* + +### Description des tables + +Chaque table remplit un rôle précis dans le traitement et l’exploitation +des données. + +| Table | Rôle | Origine des informations | +|---|---|---| +| `datasets` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import | +| `sites` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` | +| `readings` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` | +| `predictions` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision | +| `alerts` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision | +| `recommendations` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision | + +Les anomalies historiques décrites dans les JSON sont conservées +dans `datasets.metadata`. Elles servent à l’analyse des données +et ne sont pas considérées comme des alertes actuelles. + +### Relations entre les tables + +- Un site possède plusieurs mesures, prévisions et alertes. +- Un jeu de données historique contient plusieurs mesures CSV. +- Les mesures API ne sont pas rattachées à un dataset historique. +- Une alerte peut être associée à une prévision du même site. +- Une alerte peut donner lieu à plusieurs recommandations. diff --git a/docs/architecture/images/EnerVision-schema-donnees.png b/docs/architecture/images/EnerVision-schema-donnees.png new file mode 100644 index 0000000000000000000000000000000000000000..6671387d69e89abea1c7ccc4d42a5a3beb5a251e GIT binary patch literal 152794 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

ldEl;T?}Fi!KX;zJI`FeDfL#uqB7w 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

0NU<^hnK&dmSv*(Mi(+sZ})uo5iStE@xu; z<4=X`QwtLY#qNYI00Ibj*n20v5<)EDuC9K7MSri}1Q)z`6g*_HK{fHQ;sOfKCxU|C zn>}?j{Q$!(r?1k)wbTJ_z2lCS!L!=W@_!m+f~HB^U9Itn7}iA0p9I5D7?Ma1SU#lf zR&?~1RJ8UBLGTW+4RUxXqPt=69+~(v9u`v>U5-Qy`83~3Mw!?Jyu(HN^u?7mlEklS zi`-+t`s$6mYCGpwS`#YRCb)-zX$UvGBt(nEdqB=H&Zt5!@^ zjn%=*P5-8HNf++ga&@yg!>h&a=shOs3kU1%{U(ji+ErnvN_r(Rb^f!wNLpv^K~)X) z{p${-mbX1e@Z&um3r#fNM~7$NsnzI9e!1{kL!7uS4S1A|jmcr)kT(FvaO=&qWUbju z(0-;*1|XFB(p0ogH@Nw4(b8tkZj%ahM09R!s0+yg{9=@v5)73QjV7NBBMR;F1QG^# z^rX&XSSja|7wa&|;mi00d1pd_@o0$F-;y1@{hlvQN}%}Ebvx5<9%9uW(f$Kt@RW5Y zWrq8%nfqDB=n5B$fK#>iy-d`b|S&V>>zSGYX8Z7rF_wYVyah zw`a}A^Z>6)>wYPd*q+znbp!tksef+$#gxIl!}@{AgR2p{wF7AbFPP^^UFG_Y1O)86 z&`w^&8Gr{_&urr3jkzau`vyr>8~xt#i^QdG4tFS>oaJ2u|9G5#?!wiS!HUniX~v@~ z*&>BzQKwYz7>Mb}oBTzpS8<=#K4BqU9=pxd_llnYQ~QNq|;>=d95mFv=ipoM@v*#iDrL z-Fb|TnvbD)sdtxifqz|N;?u77*Sb(tWzPzWiXy_-SLi?bQF$_d%qSC;aUOf*$gJS} zbXafJ%46nlGa#PqA9}nHQA)JFed{w2r|J2ErU~pA#Tyy@BV5IbsF^s?@2Q!1D}QTH z$wC}x4zo+V>PGrpbbTzpcolk^|LG9=?t6iJ)vA5;wuo zh*x{TK2E`>=@LDkX8uFy3cKzrfwQ5jk&(Y>;dLrFxT2$-Mo&JKJ`Qn%q62hoo(eeH4 zh3CO;i8@TEo2f;($$l`h*KlmNC6eypXS(#K7jM+Q`88q49{IG-Q+y_>_}h0_BU=8{ z^ImyJucX}E+@N;$_LvR&k;S43Q9A3bmR+|aIg%LlUcrM14Ii&|0mXn13ZRZ>MDss| z$nrntHCOz$YGqpF0$ySlLte=5-3H!IWIAY9B!-0*vzf`pmDGM?#t-0(B8YCJqM_|O zM%Y}?cQJb>qhT9a?Y>W)Ux|$WF{;_b>eWVeP9DBwmW>8BS)h#N$FC3Lw=RiT^2Ugy z3mtc$5QSd^G?b%M{YqZz(Y}>m@Y$MqrwmFAao_+vwe1^hQJWCtLiCC;<>T`pme9|T zrBA6kpu%u3fpGrRD5eL*pxW}fTg0DDAR?eoEC)sw>Ay!7s$Ol$n$&=L+mxOh>~^D5 zLq#vZo%S{+s6?j_XN|z2#OBhi5Ey3Ut@WQ1V&{`#CJ_iOltjrzUJ*<1I{V?z6)Rw& z{L@PoJ*sRkr@qrKzzKz+`}LcCXXeX3p`CF-TJ-Y9Ise zKY`!^wZAcv6g&&;wh6eZuPoUzat&ZKdl-RH-bYf>yyln5Y++!H(#3%T!f>x)VSoMz z2hMMM1=M|FA=EvXI~7W0+}8rH=7l9%OMGu6Dd)gzollb>UzQMxx9>!qJvcCxnd$~^ zGEcz=4&~slg-i69V`;Cp3YyL`c^lwNAW8k}OdtxBn{=;m%ZLM;GMT(Qz$2i5jRYt# z0Dn1a?PW`$F4uWU!p@$NOK1p%;7kcbK08ifC z-93J!tW#472%^*f+QJDqmjBhBdNJYA|7DjX_i4U4>{>m2<*D(Hc?PTNZ>Rgt);6{b zI%H+F@5 zBwRNy>IuHIc+~GQUf(j`6O(SLOHYnm*1TzK=GskVuV!ROD)H!>;v1E8R(~%*jxBF} zmFuQSJ70%LvZ6U&AIYN{yW1NcI>>(7RAca=sbyv5;*(9zo~+(%8gsKIu4KuUl<^s3 zBVgZ~i-1vGuFIDEiwY5uM>X>pT7&W#Nc!B{BJCQ!$tA0DmW`TJSdcEay*Egi2R z{qJ5AvM5a+-&R*NZY=~6C&njjJh|!2Bd;jve{1rD6EMMP$21vJat+ap+SyXqwqw+iu3g?*kkm&aJR3ec5|EQkHzptbew9UTQD(HxALo7eC(&0G;3LF6%JsKg z!23M|4Fu0lPi*JI_qy@V>?*Y83e{uZr+c*Q`#k`5cPoGz<`~~h=#bgpj-5k3)Fq*D z0;Rf~8X1(Qx38vdpL31g$0c0vrmz~@jPw{qXBikEm9^9pk9+ze5{>S}faYrs18f$_ zOOsRGz^lovjm2*rwh{E7Uzx4bLNO~OGjfDta%pSA-_;b&Mr0zUJuZ$Wv8KP%DO5OF z{xfLs$ zKKQFs$Z9W%3TuJYk2-T<^sms~Nh|sI)E8THbY%Mh>yd6$<8;Im$N70Hfdj9NZTJN! z2=nrsKrc*t+;AM!JNtR~)OR+hwMF^~oaspzDM*t;1$&L-;YT(fyBBvwa_ zCDTVl%`(kMPja2p~E7eMuq>b#h zKP!^msybSstjzC#3DTNfk02Mzo^A8$681>Fr?uNXa_`C0dx55eKjsg`u^oN_tYDeXeciCTz`Qtx3MmlKyu_|g{Kt|IPbGeMl_uZ zf+x#l#ox1?4o!LlPu5(IjHbdH0NF zuZ5PR3f3N;3UOkf{z7}#pTRveS*(Kja(c~XIvc8m|8*96hR~W zfr2bk%0%Ghs(Y0eT~k=>no~m@guhY4%cimp2#TZAa?0DQ1}3wnO4PGm&uX?atS@&5 zp6uXL>hdF&Z|g62)>~`siR+-sY7Y8D$3{%LCV7~#|fuFEhvzdypY6z%q;HXM@ zJ_`xOMjKU^%<6Dm4Xj5?&3jJh(5pGes9^ZTG@#?4bYu=8c`N=^3rh88~`{`-$C0z>fgdfMRS zVDL!^8ouk`%L@{`?Mn1@lXHg%DVrea^5DE#?(=@@>r6|qIx`W1MfYg)JA5Gw$E9JqKRtT zG7jFf_idYM*(ZwhES3p>uVDE4tef`;(^|W(`OfX+N;kiTR2ybH3?fH z-xo5>l&6f6G>mUBT#LQcjvuLK22s+RX zPDQr(^T6b|T{fw>J%7i3;H~0_qaZZ7y=J&PvLK4hM-_%KeTp*i>=D!UciWo z@i{C!p8MP23)WqQ>QETU=PX2nVa&Eq8trLHC5bhj8vF}@m_Kf+*>LwRd2VRmWv0nB+B->l0zZKHlSV?jz z(=!1Dy%q{_SdS=rqx6%y`r62Oi70LWMm)-$;S@EnINmBx$z?m?(Q?e6(m8HfIT{oFr9WvEw~lE$2rmbc z^>>D|nai`MXWG@+qc&9pJJjU|@M-|ulRE={$((4(;{gqd4!s@P!Fijq=BUj5Lz{sN zS|ab~m01oq{S*w@G$7X&EN_LJ!T5IFIv##;tjLhX@A4UiZX~|h6P1*pfJqlYfkKQO zM0BrErI7qRn8hCBnaAVfokaCjnn={qdTxbE)YwGS6I>(YUOfo;=3*;zHHkYJtenOl z4C8#FP(?1`-&1q}dC|MIo=&JqxB`1p)AHXCvH3B^rOw`G8bb1Iu0}>~d{diak>33BI~vTpa{Zy0SsmlWoUUU$JJs!$DAUhd;+ItUQz zY0tNuqBlIlP)OaK^IOeZ>bGA!&`wuObFN62Pwmg}=cZ5RigliWS)`@wAu82s!Kzst z1xz%JfHG65N=vKN96vzhlLmmsQnm-8feon)%Nw`eMB#IF15tk0i!S)orzzad*+*z| zA4tYxg$LwKPAaPy1XWl$a;<`_6?QVY6|kkD@Dl_yI8IkI;+Rb~0KDN^Eg+ry-m9To zFsI+hjw8W{N{jdyj z>|sGYXJkMCE%j2wA&G5bY^<}oVe9SM za?6rQGgYL<(42o@e4)<>4?{)wiZS+Pp6jV(YYT5^b+;Y4Y-KsN1lSK91|%j}rzDJE5{%5Koiy`O(a)rN z8kfiDVO8|e0eqo`M1dP?T{u5|w<(+8?_!e7`uFC8zX*VKseTRCJj|GU(eZOEE|93& zB6U8M{CFOM4!L;i$+w$#GSaiP9xUkPZ)$FwyEUnOM~eL=7nCcTMJVgUK17CP?tu1* ze*Wzm2Z9&24IUS}fCup}K|=x1aqiF3=&84vc?AWi5+h3S@q$x&-+7xKIltv>B_}$S zF6DislZ;%=*1tmzv@oW8!1?%LNAZ^h6dQC5$stkJz~sm$B~7@3%(1_G7*nQ0GMTt& zlQD@>n{GCjariFKglOifmfbL6O4^ro%|0xZ8Zh0mK+?NaIdu^)VN zmS!89(xlO2pcfYxZ_ZXz0mZx8{nngZIz>c60^kzLkkQdaW(x;1+iv`&udk28@5u?M zgn(nPs)#7EzUWN@I&=`Qa!L9wpUlGh3TZCve7L;%urmhv&7{GJQJ%dCTsy~T=eJq>jXS>Xmjkq?=TfRp zC!#e;$KAKqDPM;7iBg)ti49D4lVkyj6(nT({XuoXxSR&m*?XorqiwkA9pUK-a#9C{o2vR4p(%FAb@`QK8a=aW269B21J?VL z{Wnv&_A1{J?3p|3_N@#>CEh0L5DGrd;S4`T8t5uid&KIKS@q15nitsKy7CnryKLh# zg)trr&UvQ#ZBCcqoS%1FbB>>}QN7jz5URTxp;X5M0njpNDy75~=a^D>IR%u3DV5Nh z4>*yEXX~HGvWAeal)jb((2aoJa(b}$tibNf-;*=XhIFY8y`-}tr|=@^d>4}5aw+BF z{f4U`%p@+Zcg7M0J+S@2-t6Z+L!R|{Ma206*jvt$NVtJ~V9!LwP@vH`NXV9fF|jAA z?LAuT&l5?-8WqA-?>xg=A02E0Ir;R~$XsD-OY{x;=M6rwDBKY%q;unyJjtP<(X4gfPcP>T;g`497Be-R z=!E!t7{fi~+hH-;H&;cZY!ib&n4TF4Cq9hu*K&0_Da1L1r1-u(;8*;G%N)6xZLY{o zbW^tcn|F7^c=SYdGetzkqSBL8^*lwBzoQhy?291_1r!z>&MSq{Jr{s^Pg%3gJ(^{BhjiDC=rKZxj%A>I8J>LHJNB5D)ka48_0vZ zx9fJ!!pO2mzt+{iw`?+85M%Pte4Xtj=5Ra8^icFO$vqHlvKcUBcEykQQ-!VecWt-f z@(&`Iqqhmcs4LAhQw~=#dG_NFcL&?=a?-gq)lvSqBw`M|gn44-6cW~w{o?99~AS=ahV4HfmeLXv*6BPxDM=@7X*-EeBDjVzL!^TXtT!cO-DXT#;0#?(m|pnRoyRwI zI9C2WpY*1nh%Ju7nOndBp+^Mp5wP4}2LjQPQ^$c=XY~|14XUOQen|5-9v{m=<=3O8 zc3}(TqKfBY!g8RXP>?fSO`2M9o4Kf^$Sf_VJLYyD7!Z4Ijg103(|h>bQZ__ziuTnE zd>z|^UQxAr@A;hKu3*7R_goyopVUoZe+`DEZM;|Ebss^PeA2I0akvrrcwZz>)E7B1 zdE9heuF*8=`t5rvQmY0OwWsIG>^zOMu?m=ehVC`&4P|7blWZl1fpYHLJrJ zvcbghMDfnndx$`1lFaQsPn%ykmRPRAu>@RUw^l z>`eHlFMQ#Q(CHM3qOmoFvk5AeXm*uWOH>gI@!@J^=9qMDLG~rCOa(bFJVPYgu9eSnV%d7INJv zM?BEbKBvB$gMVA<{Lb~TOp`@%Vrqx2RMz<&#zq$|>-XjIGwG?Q$dgZleN*T1#;cBe z+nz|FkL*!LzPh!yKTRpt#wUUmD-E_$(d-FsS{$)Bv;?AjbKzC5y|^WOQA-@4=EqyY z+C?oMXl>9yt1FoX8UjWh3ox|!>yMSJ_LlVY7=6a7==oimXPb}=PCMv*#WYs-#>rog zj+~1;tkR4Nrd7G@k9wR+BrYV8^mGzhTmyKrVp`^zjKtkiW$&pu>98#+VT?I=I}4hcrkeC1%*fMVs)tKXsu1nrjMzA( z=BJPIH5(}(t<*!Wef4vhjd+G&ohZ#=+QhVmj7WsXT_Hw6)g}1KZhERqw+4?E9np`^9((aX(A``FkM_ z>8~6AEb05Wsbh0!-5G~H`c;ECIe)$mDqDDsC9^`5>!qtn;sP^P!)1^#{K*nf>^ z3w){Rn)Y}CcE*)6W1}2qVs`1PzW$Q$QZnN*+@7{`4o)zVGn-8#2K~C|0X<%gFd!l! zk&uxE0`?`e!kNQWsWgzL@m9O$@EmdC{2PwlU+o5qQN;M#7E4FgEoW*i&UC;gtrm}u z0Q8Mkrybwdru{Lf&SJj4lqGe{6Y_jdC-6rk22P(>N?$m(z;WA!{*Jv{W&om{-K2Tl zTDS^Np`YYnG#urm}g=V7o#0eGQs%tRNp*zC}*2~ zP@)4>o=at^+Mco6mlbch?dyc^VAB?_TQQOw3?ecHd|tmTJ+rg37ef@8wgBba;pRY& zzG)}6KC-V#wuqR^ohU(X2A81NtY%nEL9mY9FWc*xyG>#(-Fdi5MA^G4wO>-~ot|Qy z+P*EPI==#7RsbTg!GP2JEk1rkpZ-S@5`TcP5AXnG1qGpjim+#WJrii>6%-YFlb8&k zCJ!ACZlBn_8VY`z%M~s0-!CSlR}fQ4j}dUt;20ISO-ZP%(+H(S|GwBlkO?`+QLerX zh&~<^JwFwV_827Z6_lGZCtkof4A$jBa$$=%U66&OZ(`j+_m9il43*guUCEDBvd`-lo|xzp zd!U3E$E{XvDx+TmHkX-BiyQQ$PtWrY$J?@(YjdtS9j@OgGSnmr+M)<-UJ?X+;(bLm zBt{3$YbO9ikZ1DW`n29DAz!8n=&FsN*8{ti_5l{EFC{;b-?N7NE|=75 zjAOok-(6Kq|pIE|Br_1omhlfK$KT zpV$X7I^QPv*Dp+Iixn;IrMSCWf#UA&?(SBKdvS;2PLSg6 z#f!VU26s7|-uLr;@4QFm%=v@NB!Q4zd*|9~t)I!*J96w8&J=K`+JGWfB}dJdG>etU zYG{ieKf~B9D-m#sRpqCV=eRHz7n>ZFCs8_YfmcVb@x|lSn&-!&f9s?OH_b2X4{#Qa z%Y7v8&ab2oWGIbOr@@d?u4IJ!&wI06V68FCrPtB5p<%KL6%r*Y1v-gvzbm ziBmMlwVS0oPpIN~y!NEI+x^^^aV}U0y-WJR{c!o^bXM~|8*Nu{i$~Dr*Dh_Qoof&!_|vAGc{G|usxU<*1xuCZOB}QMjB$n*BRUiPlDGS2Dw&skV$m z8215B@zTS7WM{TjQT(0~1pVK?eQqrwu^3EfggkLubp>z%A4;hVt^`1A4_3b1pA25_ z4z6+Bl>saNvm1t7bQF(C33H!#Fz>^jk(eaPgQ3!ElbPc}WPj&%l|aB<84D|ESWrr= z$O@{E)+OeRh?jH5<=wtxqSknCcj7U;58Fd-qM!o$Hpm7@HX< zDKjuIh?~D6HM+il%IhRnr5u~4EhaA`e&H4Y$4aUmKgD*nQ9(!6ehx3`?dxD8q4<&V zU}s46sF#E+sTQGbnlAteKdOl#Mo{t)o&WuNQ*U%pi^Z?Q_ElwN=i7NWLDE>ggY(!`L{d-^U9ceb@Ys0<>$vuu(>l`&&mK7{b%~~#?m??hC$14#jNfx|K5uMpsy8`NHoJm%f^cn47X;JUmD=D_IReK~ z=?#rK7ze3&lRc13^g`OEAHD<%6n4(bNtX9Yf602jJfC*`gD7`nJiNV$d4j{iZQFOds`5uJaWw~7Eth?TJv%7_8W&VA zw;RZj-7&G>ri$N0xe4A?mw)bF|+y)bm8%un7Zj35pgu55+}Kt=76{BX}p ztUhu&r(#mS0B;*{yjeo4a1RD4a#M~tqgC%8`_^o87}0npMUvXs?NlB__O{eiz_80y zt2_m>Htx>fW{esdf#(H>Ppm9bC)IjY1T?f*(hN~4&E+7tcea!U)4xr85pWFNz&}O(Du~< zzo+;hgOByXRg@-3*rKVBi`U0!R_NwE!nR~=RRK`%?n_LLG^nD8TV^f<{j7#5FPDZ(96jGQ-u#&W`4aw$f_D+gkE`{h zPeCYL>8~Z6RE!iT>_wf6CL%UD3xBzxL6U*nyOdN|1o3`oN5$`J=kDj2~)$C&CfD&Tqx=9mjs{8^_He z)OyhpSZrU;T#qZ`!KLO#>z%O%8#}s1#D$KOp{RId6CLc0E1WdplAo|RzhSgtp~Ge3A)7f`98s~`;*#0Oz7aPH zLmV$qvWl18xBd886&F%A-xt;Zw=^g}VVEmkji?u|%a*n|T&~lMyo>4lJM!_QSQCc( zlAyiuh5&>2iO7>m2|mr8mKZ*?h1HkHRvF$XPkj2<>f?O(5`Sm!C_BOS0J_S3K*p$Q zI&NXkS}{k*K|bMmA2Sj&*pGcqadvz(CzNZ;l*jAkofh}%+BmtY-ZARTiR3ikBA}uo zA|(Y+-*O5Ga5yQDcrTG_Ovg^DI*)o~zZ!QroGPw(n9>>xRqULLQOw=)WgYWJt?6Fn3MxjOx+Ftm8lj|ihMoa6?bKi`){wKqfLj)!P0U!LFs z(>G$#s2(#bu7cCEGxJ70Mu#miCgx8L@s*^eCS3Q`!e7wN(y=6VYp5s~k#TuDUPm_NcTO5H{XQ$`QIotMN3i*5k z?IQI|?wei#?fORdai99W?Utmq-P12WKZ(KFi6QarA!YkD|KsR2*BGI1^6Uw=m?Fpq zuFWlszB_A|a8~)OS5f!l^CN#lCt*5gR*l>7r)Mv3DEfYxwMKp|KifssQ06m!o@)>f zJaf(TDjxCZ+B zp>=h^#9?vpP(sk?ho-YlW~_wX`RhZZGLTQLvti-tvR+UD9J9Zx8XNuqfs#iq*idrm znmD+)Rcb!BZmHV%l9T=q&R_$gag~?{Rf3*_>6p=Snl8dL3r8xMogh z)v|bkiXH@tT6eU@?tLMmvrwPmyY9~zD;@|!(l$IrXZwq|ruJ{H@J+r@O;KUesZL5P z|FKNA2;DrS6^2jqHg7(y3x%7^Yj)q?pO5#tM>(5@OMOPgR;f>8z3bFsV~m$1#}?Oi zhnOu24M?Y7r`Uh)TO2(I+XnZkgyYv8IrTySj3vZ`?w@LTghH^HRHXrHw))H1TjeBMN zOGXsFGm{;9wT{+yk9bXy+cnE-Meoa=Ie7f%?zeC@S=m~?yV^@0DKY-k?4IlpU$!^> zo7?tzi>GGvnUfhHij) zt-}1aZ28t&Wpj$Loj)*ZY9sGuMrCb&?L9`CR($$9Nk~^7LP4dEQq5^lXIP)2M%})cXzs&=B*~NdJ3T$D)#TbXEo3@PzKT`iODE4wruBz@Z7oEz}n3_kz!l=N{y;I8MaQi8-L}>j4E3Y3PzZ0)aA*mnHA*p zNvz$@OEKoxxE~UoGcyd;PDp!5$F#Tqhh&B9PyoGZwSj1!j-eF@X?`)7R2zm!@Z{-=nHA@dZ0kY3LD5_x@(v zTF*Kt8&c-mz?k6M(g~YlSQZic_Sso{Fw&>5Z%;PB>ecfD#&Mrh!Dbja59uatGQ`NkQAH5>VewNF zeDC7JU@_yq} z+Zy*lMg3BlQW#hD4 znV}j=9^Og174GGcT2LM1InURT(Pp(u zTEl;5pG~X*-vPdh4qrY3A3PrOcfNvxI8E2ql(I4fE1$PmGhR6 zAMVgSg5VKF{9lPg)zm+4LdL&BS$rujEbEZ~A+x_%H(6>GWgug!)O@hAN0he^TZ|1k z8Jv%>^3iolQi$tI{EDwSN)UQe0MsnNP!a;j;*j+4;9_60dz+-=K?yvXFPU@>$P+Js zj_!571Xvx67Rl#wIBdbBu~~0)LK6n;7#jP&j3UW-TD1Fkv_A>U`mcb$x=Bu2x6-a# zYvN?p0r@GTQ+&XVxc9^bZ~KOYkIbod-%9-s{4MmNV(W{bCx%=e#Oexml+m~MvuuT8FF8UB+CGB>|?8HSxbF2__ zc$L#dQ{8g9L`kD{a_-c2jqiht3rT|Bu?asv0e6D>esZ(NEAMM!WVhoGeZy zjRf6*?~-g8$`i z><$RV(G*;s*%?Xe2kf?kCjpGySw;M4GM5FuQ6Gg}cfxtkg-Z#_x9eVZGs*V_e=?1{ zeFC99Mwjm#COX}X$1CH;WJ5H&kLBK@0(ZqdwB>Kle!EYR2w7LAHFUD2#56)KGsnvk zpC9+*hlsegVtmO|lDyKqm_KJmfRG6edWW{8y#^$#I=VFf+mnTl6CXcvBx}nENy06* zwHNd`$LEn&@|Oo_fO{~$nCCH3``vB{TEu_QJArhiqzn~y0ug9*>ZFS~s4wzIERd&X zcTb~{VVrjt#^3q)0jBYF-I219)o$W z;7-z&9}gqh-F&6tb}XpFn6swoTf5lBNzr=RhAs$6wjj1GjbB$#x zbvXKe9WNa17%2>;e-jiEU`kQ8>F_Vi4!Naew320GS3Ws-goQ=;vPw4MBA25ys(G^zHFUGj!fPM56h7DOb{M`MA|`mh4FA zD4ys*Mp?Rr)jh z&$%M8aAFTfnov>%X;3a#n*V&S(Q&B$(DP>;E_w*mjSVAd!~Ipv3wF|vg8GB(I^z!-rp-p<#09seSBd_i{p3~|8rRd*@7-SBSk=KI+ZT>2a&)I3llKqWs zGOqd;4SB`nIlbhb?&#I-W9HyoNp;z>GcF7r?nrD$n;hRh3`BtFtQ^R2G;!GAI1U1i z=%l)y=o2TR%_ll1B3T}eXm_ONn1|2T)pVU6=lS!oAu?fGE957;QFlmZ9hW~Ym^*4s ztMspky!vH++A0%}+Q?bNs$F>#2^f;2sMkcI+i$CdBgI0B(WU&!vMgJ!Ad6p_mEZm7cfsQln(frXlBv39PBUpJHnZ@p1Q>;H) z2E}_9e3RV*crS8kL#YlFhj6Q(um)=|_@vO`snkK}7O^U4-ulRfHo|brWLHZp~N*88~=;ITl)7kqP^7pm|KFRHRI& zRZ;fI%brVgvg$=C}s~DS5hh)7S zAC)rrm1dzFnvs6PPU*iETONrfVF5LDSbW0d)8+mGDe>- zx=^BKMWyC1X>j&clkk~cJ)5QO&-s8;cQ?(HKF*i^2JRD*Pxr!^o>$H~?(2@%iyq8A zUT<-AJu_ZJ>Pvo1MH)P7x6VHg*0ekxIJO{f4`qBH%eH+Y*?7!HB&lu>e4g!9z^Gp~k-%bF5iY;e-MIC{J|=_gf)qv#$7{-b{1kj!<9%1p8s~brtFLfG- zqrYCxFN07XH^)>_rz)m|*Y|E-)&g^pPInsiq8=uF2D4@nw+Ve46LqGlE=Jnp97fW3 zO71I*pEhy$+#GOGb$!B8o(YCmc}E;YPd0N`r7nG{9aA5~XtG40?PhtCHWUd)JaOS^ zlGR6=9>sZhOpy-}OuSI; zP2h3zeupECvIx7&&GDsSf=9ZPwm!Oy{8NMau2z7_Ub|JXcxmAuA_0Rs8?ePMvsZdVkV{-o^WRQIMurS*^J`-&jlm|yAZ_8h+6o)we_#6NNd5cV-GhGZ~mVg{Xm=eKs> zUFag-T3!Ba*KFV_U{7`gOYiW2irdVY5(?ZuBmQ%F+@MhUp=sQ;n!yT}p8tW#w)L5b zo+dP#!ju+7?~|Cc>XuMnZ<)g9{O!hs)+u+#Q5AgLa(*<_a=-N9>)p!0ckyya?x%M< zqi2=ZjhJpvSM0ZSanCQ8G&o#-s?72eE1^#c@;DteJ6&KrrKv$BqBT) zPra8=m%d%9QsZ^Ub}s9zw{HH)w_jtXOidN%%|)X`!oqr*`+yqyw?r+peC!0(4;2z{-n5Z zh`o9%jtMH7ZtZZS+x>RX;^Xw}*{o-uztS3DPN~6}L{b8^#iab>tzx3>JWgDNp*8d^W@CCZeA5(C>y+`#W@AX^G z@BLH#voQl8`hdL!S29bBdrC$ef5{!pwK^d*1>P{omkO?SihJO`wy3{2cU99lv~VtU zKp@)pNy|Z+mR*1L6&orS(Vd{4u!HMgxkxO_r2uXD=-iD5$V^x|QG_3xM6jsE@y|Xq z+|FD8W$oXD0jJq`(BPje{TQ2%i4_Ku_p)lPEtpC0uxB=80J@UST39x9@Lj{S>jh}( z9F0}K+yLgok>l-?b}Ho;$4*aof}7~TVj`3S57)N6uK2MCaKo$Y_TgKi&J!`r#NUPf zr30+9#;h>#l!)aXRZD*H=OgHU*fWR;5`mq19Y#zUXWeEjF4!yABzHC_z}q68>fCI! zZwiV?#dUkI=VywJ+$tD)aA!w-ZSeN6El_ho?NRGc;MYRACs6p6XTyO|veR0iDI&Wk zXK46|o$u})(&ICjXKFQ2_*q8qkpsvl=IYSrz6c(gpNjss?n)E%AuyHkl3*M ziiLa(!CR{Qgb_2oTu6`SR31b15yuo^&!xrk9C;Iy~3icJzC#sdErrbd&yhGCSGtr?kuHTx|{m(c#TGE{QyptR9EeEbw zert;yT%&;kt*4b#FP5^Nf5)=xtlPfaa5UFhpV@rw;WT`>KJMx3t9#Va)Rc5`Vu1i+ z=8ona&%X;B8#XMzO$JyAQatU@~IbxbvU_Kj-DzULyjrPoU};#NY+xE8~Nu z($FagiaDwpvH2`1U<1lPlhedGaNh=x{o!}`1onF5M0(k|vC?UuX-+|2&Yk*EV@&;i z25~h%L&0Klu9i2vkB*I&o}QeHEC@hcAlB?SD5$C5b^z|w=;*@I(g+Z2X^~-JVQh>4 zp%7We@&V_7ISw9dlC;vTNn{Yh#|@X?n|@nP=wog9>u2qIb2zi&25{ffO7@c69rt|( zC2BY;^|{~6Ac@ws^$!Lx!66zs{Hlw1jL<5fAxM8;dN+o<`opK;TPv7zb^D} z8R2t`Yp^V-H`ql1E_LWydm4@0%2@TtN5VpTje?lF_FWp}Nv^sPI{R8x+Q3|$|JJXs zu`Ydz3ajp6z(LY#127dHd?^W+!}9ubU2HpS-yhhd);4_Z&4*`P83-y$o!wZz#~Cz_ zy|nP$F)1bf*sRuQ0^S97n_ZgNtBcb2L2B@`G)8^K%ndtz&s#RUYtx#RY!h`PDE2p~ zON_33A5PL_K!}h_shiV}7rmy%pwjOB%8M0E(Ah}gi{Vq)_EODW(#lBS9u%#gzsN@d zEU~N@LoBS5w{w(H>XHREXt~guVvj!bdqSt>OP!+;Ua@ugo3KpT6Ht@BZttGoyv5@3 zz#{4!MVi0AIu!l-^&N0JU+DB!>UT5XMCRo=@i~S3`~6+YbKt-`9y6d9WJ??Il*e3( z?*|w9OrXQhdV(x>=*WG1dE+XuMvGHn&J>w(A^~Cn}v;`~oO>r^TjFNtdiYJ3s z0HIqnw4C3_Ox=V^?%7XUM1z+*6HM581)2II#4QH?? zy0ETABCMHlxd3LQw#l51WZBu-E!PW{JhyB9z_xJyM+oWcHOKMpVmEhk-whx$H9i3T zV}O5o=lA#0PkY`S{q@$X<(C)CKNXyn1zt8aFw1vxK`dU4J>oyxZ#3)~xt}&&0rKA8 z>kL6ObaU(SuP#9?77vT|3iTGZvznC;^dfYsnA7?v8WIzezbP%h9It-RHGN~b`VzFU zW*0$#s{h8qVuaJk2tz*iSU4mL%JNxb<)t8b&5oErkzPsJU1>ZV!XGYP194Xc6>DR) z!q(S&SD9_&Gk}(3)TC{CL!*>%x1yTnlSL*k_wRF4#GhD|Ok1>KhT2S+>1|*k)jvz= z54s_>3h~0@Z|UptBME(`ukQbX^yJ}5L1Etfa)3U?VsiS&k02=8sZM=1YETT+iJHu0 z`7yB;9LKqabDv~QI_b(bDd-R)Ipa95+|5`rJ?G%6$azx#Xt1IzmClrKM(p8v1C_aH zWFy|o;>kwDi92wbGLRnw#e;tKwO1qAdC1s0mHy?C8RKck6~K@MM$R^m=23>0kzQhb z-hN*Eg}QM$S1dLn!0p1LuhC&x_&GN=L=%(AaN}K{6anJ5+#`tM1P>lo8INeZZ{i39 z!6{`tw8VYa`O6d&bkGfHQQEHwmK}a?_oquS0qT_1QUeMhAt9Mex|o8($6V29vQM8N z{5!t@>B>OnX#_TN0ibh2bSi8+K>IoSy!FP$r7bLvh1GSbOpnD4gpOH57rh$DfiwVs zgUF4`Helg@jwl^^!w6UFcW?#C!FJLdE@OCTTdmTa_GpXGK+6?~!wZx#&>%m{K+Y4H zpGE}>@BE{VHLc3`o>=Int-F20tI=hLLqz+6S!wktQawC8rv=N<-`CyD?SnJWbYsQn7wG@ z7@PgEhBj7`a^3E}?$!o_E`}hfl)*pWM(JTLr=~v<$yBllULvv{=A==@c?NqlTtNj6 zsH(N5)otXNJuP}AZFK3{W)CuE2Fi~1H#8MP-h`$I)960FxzpcFsai|Y+V}moEvcel zaTEzl&6mVCEHryYkZDB7(&oHC76UK-MnpH>VD+v)pX6okWB8I-5!g;D14~Ifb^Hbo&*s5vFn8<{Ld_ z{<5?0w+cMD&hrwDd)ohs%y7fl*_s2X48I$z2!==whModO)4G#8^aLN#&)Z&7w?c4} zr>WB-?KKFz)|lYf>38*3_X7wd$kuiiQ}%2()lATIPmqjP(iz`D1+T51v@O-LnL(@>H|ZpJb&{dE*;*>DtqCAYT#*jo5VFCGwE4w% zuL8EjM1T6?YxAW$Y%{X zg#!-&&f$Z06jWgCdfm$=UpGRzU)g7BVbZHsGGUHxs0xZP?ksg#2wBbK}re=Ax5xilIlfABH&rx)70`}N&YSf zdwNC8(ZWvj%w?ziL)$HVVM)7(EPlOm1`-NtEHl)+pD8Rzd+7EPtC`fhK&Z7tOer^T zMYkrAj}#7qmU~Jt`^);yRqt~1h_c~je|t9olK58vQf#?g*1Jv1sc+2@LYg^q1l}7} zfx{9Y&Dtq0sX`drH&ShFG;VA91U`?hA6LQ%3ItXGu;q^nK2Yjk9(QY|nkrhEFkoo_ zMSq6Oti#W}&CSV=E06%37`IMq{Qy+&KrK$#2w+|{H35W%KcP&u(Wf0+Prpwpb~9v* ztP61QiDDcV`3E^3KlyI-^2=u1xug9QEO%IUpj@LN;Twb9N{f%QtE@ zq*7O3aUmgGNvmcqC6LCmvGukbKVV?;CLp#BhFVM_b1y}IeJN%!Q35!^MGzih zR%4phwOaR3P9C;H`@6L`)hhHtJ|wR|J3;+97<~k!6K$qckbengK2#FMOpCFDo)mzf zkDxsXPju3kFzPP3b{`QevH@A`{otZfrZ#@=XlO_bIyDb=P3 zj-EjJI~4faX>Iz})!6wj`BG6uq&wlZb7`r=D@Pt^Nq&~5(Dh(XtA-nDWK7%y0@-OF z7Xpf$y~@aO;-HTPEH#D~0&ro>V$|$RWH~{}2)z+4-UM;Ho}q(>y?dQ+%&rJZuEASh zv8(K5_XAjYR;@Zlz z1iFMO;h?Er6@&(d@fh2 zfQ1dh!VkU2UsUnuPhXQRH%*gnL^sQN)8hn8G0=2 zqQP3t3dhS!bM^C*{pyCnEQ?7_pXlmAqAez%g=uO!o;m(GUfx-lyJ*vTIpszp=+vvV zI_;wZV_y-&XxH0VcDQ-Ny!D?tHL7j72~wr4%3r|OD_L@}9Bm^w&d||5d*1_Ii~O!s zcRlyWPYU0Plv91Ad~7N5{tz=1=Uy~3MW^#cC&FNl@VSTP;(@2}l0t|ql2jp0R7*qG z=WNM8;51Fl1g`8#>*+>qmfcP;D6&b@xwkf!*rc zWi}hvO)@+I9L&-!0| zeX)5xSkl;ScVvC3OP zoG8F;#BCj{ZQ6pu#2}1xfCP{u~b&doLYaj_Z9!dKG z>?n=|BL14*vwS)&!txYGArP4(ESw8Tp$1o1>2PcltH{haJENeYByQ-Rav30$Y~7l| z2)2OA;uvag5pnZjm>$bL1ZCmEY4N$B;%hnsJ0^+i2sdq>w%+7@9go6(3XoFIJ+|AxPl_X!C( zZo{8Tsj1SRkmv7UiRHTuuzJuMGP)W%y640!DN4g-bB1u ziDMWH*8k5?b(R3LfgFXjC_ZGl{cyh^*owXpu5{IgoKXb6BHt3%I*CJJ;Eg(p7~wzM zyaCkq<)p&wVd6qGM;i5*mqfS9N1qPl_&g0J&fcZSS^8u1Fn#ancoTuc9ATF=dQk^d z9f09S9{@17ssd@oqku)oT%%**O-W73#p;)T#tKBE>?e3Ctyhq?UUhLTIjxU3Am8;n zU+=!CBd?8>7hGF#k$J_vN2qF>cuwA@MAxjBkbnilEfOP#yt;^^OD(DcN3|;uyW8dy zYhH3`Rnt(YJNU!LvTwvm3G_Vurf0qJI|QGmy_hgCFp-;6@@To|l2U)49G`8?Qhxg{ zENZ$A`9u+kQ1thh%IcJ8tQ2!2;#Tuj(NjIkR?%aHG74Hm3-VU|96GAv=LJ`Q6R02B zm6=E|l^)IjD=JRN5V*gLvU6lT0nWdjP?{KR4`x3XUj_bb(tB3NKZr{B(RCrr7 zzOUrAq$jO8xQgj3W+g!&EQ(`hzVasj42!i%PcW@;{!c*QnxZFs_=Npx4te@A>a zZ;&rvKzB;8to+sX(Cv`ucqtfdXT_UZ(WCT?1uOaTbaTn^K`Q7WlgHZuZ>eK+Es&?y zjL&t+nV&uCAVrg;m$Sg1itfMRcM5-kZ{@3q!-VcUL&R^8jTCYb2!?-$e1U|HFc_}1 zYoY+))SSHQecAs>OnkRaqtjnl9Orfr1=aSj@#zZg+xOZ_h6I~0#iXF$(r#IC1L^-w zqdfXiJsqu&e1m-#iAKYXdiJmPR5@9ht*~_%Ntd>mj`qbePGg|j!zH;K-zYmv@+JA{0i0fttwGEbzwSD>ZqGf~Yd>)sN%@Y{fuX^z)2|8a0Rxj%2 z!c~#$UZX_iY`b^Y@wj5;8&*Iqc*Ek2&EMu132Ghn%?+1foBdzGTD64K+LtuWpJJY9 zk94bI(Q#wHp!UU6d&Javah9sko8HxVRbp0Jd*a9K(rqP5O3dM@hM~$qi%hImXQ5N8 zRuUBtHt!-L0GG+%zs1145D|Esv8ShY;9k>6DTA3K(X@90A&y$I2t~AC0bho{n$aKP z!#$0qlVEhy&ytxDh6ib~4AS6F&l!aw?dQ@y{KCe`)xn!|Utbd9AXmMu>4GQ!2fiuv{bcuej-AzR;n%y>{?<6Ito+A! zuTsv`3UWrv0-y^xDw6i0y?}RjBx93Rxo$q=!1H)qkQIE#Q8m-daQomkLhcABLw!Z} z_F2cfHnXzr7!LPJh2gF~^-%2bw};R7o0+)eCuFA3lz+xef{)2uCHG9t*?0MrPd4Y;ya#X3tmy{0+5gI$?E$P5+oEXg|g0k>q5q0@=E0c`WC{sDVo zN2Y-sFJkWj(~_&X8;M|LT^pQonahF-*@@*Ss4|rAtx}_$VlYM5L%CQfS*n0eXbHd) z0LPT!ZJ>OwO>LD63IC>2DqMPe@Ix^qbKH=8K`^971QNhW;=t(kL3?;48(7Ivg$6qNl; z^XScSLJ9nRb6arJp8T7~%Gi%+XVabj(03!YKHc2lmTn;D-jmI0Zf*JNBi7RP(Sr>( zcbEs+BabQxDQQe%;>@|DkkH$Q8!+@IKEBMdva&?y>FH@e7h3;F^Y4GYF~^|Sc`UmT z1a`rkLT4TGg95HQM;%W?(qIcbJnkL+4$@Y}J1A66BMxsI#gG*rtb~eh<<#t`x_h6> zvIIq@xY&*d@)?a7V*I#%qFl5^maU+PmZ%ds$?-$}TI2Hw*<-bwZ=%oG;Nm4JP*7<( zgQ7;7UgL0qm)#P(FBuYF!RXzY(*_)pU?L(&=6=e#Go&5|EAPS8RXaHH1V%C3Q(}KD zU+l?T5)p(F{a947wYFouwJBu0M8!si*J=6(xhvw>mKOv9?arHKZ6~Pe=uhN{D=8`2 zt#!NsbQWLL)RJ8t?StYCL(3iw*dvo7KId!$fS9x}>q1EO<<*1VY-NHWNyX#$<;Wxk zsxGky-~GwSvqN510Ea(MS!na-W@Kd4^10jIyYjHGw4ARoMM5Q)Apwfgh6auT*{mp) z((T_d#DKdJlgTg^gcFx`p%4YYlg;vi2|V&b7dN*!X;AMpJJq44Gs*A;!Pxsuyb6^A zIKYj|m^=8+f=;5c$Frn=)!Hgf9y!NU`WFv`A2T$gMGJ+;uk{sf1y*kRlko8Cf0WYb zUhKZy5LvnyE4uOoA6u%{_-x9=Qb)yN)<@Nz+1-ti>+3$ixBIw5&mf~3%fDUf5dacWDSr1Z|fMeAsd}GCe-HvRX=!>K*6Vm?Re6}(O*uD6(&aU@IMxq&GV zFd((mlz}HDyK9$_;(s>Z;PIfp(w31<#SDFomp6sl2l2w>;?}z2_Z&Q5W%A(xDL*bV zQ%0qnznj=!!zRN5e~p04DdcRkzs7Qwj!ZT)V%ma~ojnQAQ^&@~_vDGkaoDXv0EYg# zfNFALLIKzr-e&_BI$*%V>zqU1)A$wG$QS|A@;wp_|LdH?69ZCY%m=pzf7iRibRV~2;rKbE8e<{ID-_feJ{P>nzMJ*i zwqraeiUw_hKf#PAiR5={7wyb1QSY_TOFB)v-nKlC6*iJ8guBe)^IY|NHteV3rm(4n zv+@^CD@JcTyCzm{4IIK;uzYM>v*vTV0S)i(c4{e2Oh~al3p_t%A-TD)BjX_Tq3BNf z+y(>5d$mdnQH1=jNfQuhBSBeNS+0uqK~wk0K@ms6?PTBH81^y!sz*7^9;@_e2uG4v z%7vhdLdV-E@`dQXHp+o>jKj$i7|+};8)Ra#*!W`M>Lsky_I7riW9Fp9uw!cAs|OWj z{Q(R%Q>@Q34kT2B{?VTmEPKQ9s&#fRrcKX;n(akew7F<`yFH5MH~n*Q#h-^WB)s}i zhJ&wBbd8L*7?6ThKt$Qus?D>dGw&JKV%N2pIyz#?1dsThGYEb~;&FaV8WJ`!DY&5m zj-C_%W&-EsN@)NG2;X^7hyZg%2-vXq#7uV~frHb-*%Q68{#>NYhZRW`k=+VAQ*_3N zE$`0b7LQ|bAh2DM|Enm3_>E$+=aAC9MR29vSq%@vHI}8WM|`u-72)-)`}@*C`J628 z5aDosV@nS{QQSAtyIUUZD*+Fs@^@H_B@qGFh$6YyokmLQ=F>9Z!(f4OA`%Xm1cUCG zxe}x`z&X=kt-}w9i9{7P_do#NYRk*>!xA7Kw_XS%{DLdBb=r1|D}=>i!6MWhSu{=N zfkYq{(x%K>41f`BM9E&=dzkCNqnmlZP8qJYq30Ud5_!TS4+f$3n4>6BL_;*|BWYAQ zyq|u(6Gbcpwa5JM%f;el!OO)tor=Y+2Z+!4Y8KJjnYfffoY#8#d^E_z`26${0+R8Z+`5SEy zC!0VcM3>_27pD4}-7hu7}JG*~4V16nlPk>WS=W=2)rrnhRn2}C#4{aV!_meUCS zaI-O)A$gQhNI}>mG11cHFtpe09^gJvN|q8iBQ>q@vmgCC^-|>L=KY zljU3Q#ndHXVfiI(yAdpp9f_rdpk@W9Vo9vsnvq&`dUi!>_UM&)J30Ayhvd7qvfo)m zizsNyKT7E9OzbVeUd>pAC~VwgW;d=^m919JvcNQ7O_y}a8GU}J+5a&wC+_CQTT6fZ zULEP$-VhL|_%LwNbGidxuWi;^whukm$R#T9!5QSUheFKos~t0Kgni z#4aIfUl>G$FRNZ~OF%Y64+1g&Po1@hR)UMcu5ZOFzA`P*N|I=0pWh%?8uyKXaF&rgpvJP$Km4$bzK$I7bOz!xX* z#sI>mVI2xvMBwjAhU`>b;v@EB^S6LK9;%A*5c`Wid<9lGgaF9yYQc2w2&}C@i3ITn zxfrgwWKC*_5|cqnkwZnej;m=#D}rUejSUEFY0q%su(sW@^QTNI1Pc*<16AaU-*8-? zEoAr_-29qVd){)>NJMYa95(J@i$TTXhnns4 zO)nm}I5>dGM?yjZkb|Mmz%p{Gtf=TeVv;MH#RsJ2q2l0#$;@8z07tAri>|A?`wbvI z?VO#hS01y*h7Zbm^lqZl7!lPqzjX+W`{E0(-7qkz0FMF<>Tc5Wvj~9TrOyHok&EYU zQe7a&;{R9tre$RjiWu%b99PV^CIM6?pq>skV|@Qu2_OWfaAvQho-^2^>DRG;D&*fm zKNvfpzTEBfC(~{12irHVx*p$`2VI~O=k+-Y*L~c|p6eBuENA#dyub#?HM~<60Nq<^ zBJgsNZ8gB{w$NUOK){1VOP8k1;Z>c_3*gV#3?LCOaBw-mM}@cqzSFOF5fN=*ZT~ve zjN;lbH_sg9Z8?D_4xvQVArIT`zZ+NGIXOiqHk@)~EH~7a<9Yy%kC|@}Imiw?n?+1z%t3yPuNS{CSuj;H!=ANy*7#T3T8|V=m1Z<(l=7m9Ai&Yw+r7bwx!* zly3- z%J2al1+>BeC3Q(&Ri~6k7twH{#xN&2Ig5$~Qkq=!6KU1jyxlBef5V}8)Dy>cUU*_Y_NsXUZc*6<28*0d48G&pf^P{ND= zmz|p%_ghz&ph~&MD`$rV=|gf>*8kw_t)lAK+HFw+0Rll1+zApixH|+3?(XhP+})kv z?he5{xI=JvcXzi_WUc?~efGKcKHLZ9gw|kEHL7ZiZ}hLr>YJI_KaNPi;In>ZK^0l- zFO;=P(NnJD#tn2t26JSC-n31gZ6sfOpc~)Et>ff6>FTD^5UX=kI-!@XFL0tVr##k1 zqIupP#VKV6I(uz;^Al3dDeBEfhE)?oxKC;eY{_Yof=WhDH=-8|DZV%a-AT5)6$e_N zv))IuA9PB3_B$1Z1F-ZdyMZ=&G)4mpb>2w~o=h|?7w{rqA-h{2Iq|>NAUbtOj1+sG z_hO$`lk|w!DwwosxWQmPPO$2hWDzqYz?S$vP1BtJVG)T%>ApC*#E+6LUmH>BXjZWz!o!MAAkwoil3d3ZtxF3bc~6L7jgCtJlnr)(3Q50Yif#^ z;D;f`r^qU6Bo-^8>2k~cEauEk4pS66lb+44IGs>rTTUVi@#btzXy8fzwHDTnKGj9W z1Mk&iTM*A_iw^H`Go#UUoMvF~&y}Ua_{ChPh!mxn0>P}fwJw{B-^0y-B{rTCS6}3m z!zZx%eCY(d;T7QSAbLm84U-N95rgNE>S~!WV6|GNO92f%+o#)XNiZv!(vh0-5L3DB z<(NZ~4h0v3w{z}lX|!Xd@pxRf&9EM@4+o3Y`HKT&AX+Xz(}&e)xn3)tLKm3wU{tZ~ z1%Ed7|0%|pYuEd@9U_QG_Zrc3KfLtgLN2u3`X@YxugcvN;m)cx`t2$!>~%*(J^D(` zdqg}D0qrjkLg6~f4T##LG^SMKzh%U+baiHbw`3xAlA3Ntwnu) z4~3vTm8j$QX!p{3Fk6lfM968MuC%O_U;y!|Br(CUu_H9eFLUg#@NcySFuiTKZI0+n zwrOtoj_Q$r8zwvm_pG1?Y$)%eS?<=lnDmoNOypBh%H4w<6G&wgF$Rrj+yz8iz-Q!J zT~8F4jS^&Q+{x)wBsskb2m?mjMB4*=JDF(CZgzpJxt-|vXps|d@rarDsEjDFIKlt8 zTMJWTYkEX64SbFL>N)JGsV1)reH^607>@*I`?E9lYxo!ZwdPa(bMqs%sMX4mM$SANeB6;J7=5up#(f)g7~KQn25uf>N5nfg1{u3JB7>b*{ z9!HNu*rt6PF-Udv;TrpgOr)V*Avlmk@v&h4E5%#{Zb2D+lEmR`wFf`!!FUdg0*X90 z-}{tg55!u@z(1Qpl?rk>AHOymNY2ZFXqx~3ON(4bahJRoEt|p<6EU!`u$q-BwL8E8 z4QFWA_`N|BzFc3%r@uufFx5snn-i!%toaTlx$?BB&t*)NlCvgLw)Aq}vD$)C20m%P z(s6{TGrg*kXwU_kJs5pTFz)i8O|E%&dok#GqCN7ANXyo37H`tWSJw-9dCBulcVwxl zVY7D-ljpxN2OqcYCL*@rj}SHbX--r6hHsyvI0$c$VvHd<=X}f8${u%ZCp%ZI`1QGy zdX5kH)GDLuz6J+1WvoRsUaDBF+PT<&TFWmW1|q6Yp)#+mU|j3^E?!c~ZIAXow5RaP|TLd70T6NmQMSLH{y6M)IyrL_4gOT zfI~7d>du8Fakga1rx{|b$H>mu>LiFf>Y5fPh6d0Vk7pq|b7NK^wnwyV?5jOa_^HKr zOY-rt6LT&~1vgoq0<=1EC+ktb?&58S5d}D=np(jw&q*FnW(2L#<}Y1-h~&D#0KPud zhXasCThrru|4(u#CWPof1Gs&SEf|fBAEGrfzpj*yDrDWb$SXx5te(9py zy2|_8l(CCnWA5;PE+BwKLc9oun6)7fr`oy6^q?lo&rW4LN4unq4;yEJb09BZ?Gq5z zq9QG=0|#q~a6M&QOTn3js!h&(fF0)N$IMm4P8+M)d!tA8bjky&Uh@uYxWBm{(r{vN z$?@K=E?;k~e+l>G&2+pBP0k<1=!J(RHNVn-Pz_-#E~JC<-zYG9cAU=a^j=;G+E`8C z&hprsA_3b0RFF>b+1ox11(jQU_kA|VmLvGh?+Bx$p`$Zg zO>=f&R$kqC#4nw)CEB;gfsRynY>@7LPo?R34;kN`sK&zTZ)skDJ)HKjYwqR(5ChE| zHa~SD=?)#!{Mr{<9q6pEXnq3J64wud%@xD}2+2(2;hPa05J4X2Y?|)RsG4_fJP}a^ zmCrqrxIgqgKRzn`((ef%9`mEy4$;h|(i$nA9#mZ53532c(;)|0;{>|RB2i z^w2Yyp(!UFO6tuAQ8rw)9>UDB_CMX&9+Ycz8)TVUZGCQ{$IS5f>=)J$tJ4B6!H>uh zt@5fs#ekP8Sb?S9Xr`<{8|=(-wR?9X6^!Wa#imy~b@UL+`uccx?^lQZPBO2p?TI7! zW9XZ#r{0z3Bzn;h@L%araxDJM4_PqnqhZlGT9~~al%!WGYCoM%3+0!zU_PFE?Lc}O zZ_ID4TRSI{^2Dlu4Ax`PqpPHM!N43Dz5-M43sr*c+lEMbb5T(HEI1sVGamj%1UcR6 z1a^k4p<;TL(BYQw+z)l0I-xz^0kRMn(1!pxWpDxc&o#q_U8c22I5^@!t=!t$x@RZx zYN}X*8ra1LB#<-WQ$9XAdU%;0Wu30dx&P}m_-Ug#kJ*{*?nYt7eN$y+{$LcI2Y=dT zV(B|eGMi&SpaUV!1uD$i0ZWYf*nYwP(wKMT}Wr$@3p?feRt$` z&9?HqOZjwI@;v7nX{6sJwBmZ$c4W?k;$ykxW(mJI5{<64vz(x*-Lx-hnUo1yR%6-6 zbU(!5Jj~eC0=0}hQo@ecQi(xm_fF4V^xB3x@6<=yT>bd!>2dNE2E>uu$qOlhJyYR4 z%?Br!k5pVG-JnNRlqcLjkHvpu4ate14&TS7s32;&FIC3fcSj?`!<( z8KKKMIy%6BeRwTkJwOsu^tHLCBm+O?pY1VaF1S+AW#k*FQbh^1A%hFI&riv-k1P-( zEkkOTv_B-K7m#7|ivhP#kB{yZs21QXdiNX%t=h<*^cvb{vtTYN{2{w>!Qvuu;}Clv z3l)(ZX??*Ww~4$E`Ey6aI(gwQX;IR8;`H39|A$1cX(heKMIO?ML(g`5*2HATJr~$W z4XVI=ncm3Mm%@dPTh~Xc%TgjqGuHP#<3VDZwtsewB`Bb)hR@>7eSoOcWf>cz%tJWGY23ATP{EfcGj2LIt3V7f>hbj+Ip{BV& zX}IDmAy_JLd>cWEER79Ib0sgk&1CkYHGlXP0O{ya^}_$wCSwFBkD>n<*p5*C*9Wr0 zTg7dn3B+S_?H{+fhU2Y`85Z{RTSirP`p z*?^a~vbW*Kym=iy%E|<%vv1e>ndW~wWPN{obHGxGH#j=lXU<&zv@|#OLsAj}44@AH zou-;!X2!;u6%`fRM*q}J-HlTJ>{f#==U$#4@7H`0Rf|mk#AXg?9)Vd~Tf5z@2Lk+1 zun1fZq3_?nryT!9ERTtyHN2q6D6&_qj9@wrhyt$rR|KqYUGSk1=$jIYS3iC)Mz z5arszVXzmSbjiD+Wiz9u-UUOZ+#z;))V@=m=hnyaiy3h(Bns8- zSr_Hukh-7^Dplllvj>-NiE*DyOzVk8t(wxx6ygK+MBu?S&E-ry)KmC%tb7UvTSde* z&1>j!xW~_?bP(bLgM|$b9M3V1#Dop%1}d8m6(Q*R5*Y^52;6fJ10-XsF&; zCV;PC+1T19v04*~h=?rNO-cY{p7ik?;Y~Ysaw;l=p+vedH|e6;L$!x%z!ul@LS(4w zQh_+HsBzN}GN;3}v>|byi&`9Oiy-pjK^CAQSUva)oJkF)@qSk~2T(s#`_ZY98*!Sq zwgTdoaYZ&PW^dl?@<6uMYUhtMUtjEv4;pvx5l2$`d_9{ZgIW%MRJeegqYFCPa|7{r z(-)2C)e|}c(mKAmnSQ=JZ3uk?;rKtSx#)(^R-P@G3?@siM?^I%bR_zEL^ z#_0Ew(ZfIkG&2LCs$wCQC@}%bbG4RlnwgS0zNsv?o9^5<&CCoGgOmPyZ*K?;CPR#M z?lk>Ww{Hru1B& zk93qH4%-#@9M-K(Z5=&hI$%`uG?h8+6xFZkPnscze5qD_MhRtLS)RoWRwOKnC$rj2?!iy z6!MQ(dG*zaJ`c$6O0hCtKEDr?SWg%Jr?{^aw$w|_a6=8jj}i^k-0pVX;3}ry(1SJF z7`{}gSkv^l-lGCzrLZZ2#R?;(@y!{|3I9tf-VHuPUZyu1!HJHJZbpZo1+8%2953zy z!DMffNKj}*Hd({+34=%KF~_1l+?yS^k>IZ08vB6OBN~Wik#A!qE!Q=lc6Uc43~MEH z$pF8~L5q-sxnS}zLhI~O=kwi6_gN2%tBAykm_v)tAZ@H@Z zNDR@S@u7%eXmYZVFQrTUi9uMp%qR>OH_qZWM_(z*(Q}}oGpAh(7usSV1O;*G{OOv@ zL1Oi#eeFzG;Q;kEd-f2pky5uA`1%&KGd3m-;H&HF>&CUJHL6sADfJtE{T>?HH_V%I z#P*+dc{IRx^%v>|9M}mH277=WY~{SU(UY;cIbc8qz@^Pipz#>JwPFCupAQcMCN6cr zGqDUh#eVUil1%7H{I_C#P($UkTE|vo<+NkYzeU?@4|?_@pYL>d$7&$5V-}?(C0Y|! zOh%NP=Q8ZL-2YCN1N}{wYhsmgKnvkt8Wv-b+-|3(1}f>I-sIG?OD#3EmEbfvF|lzbfH1oXwE15gZOat z;am?$NMmGX1|sMzgmOOf@bH}WPyrNzgvaNTgNI~7Nzx4O_C$JMjhhOuXE%NGKD1!S@F?NC^ zlBgSfy8t^Ggs@zGZ!hPdjZH56`}N)~p|6^y;BnMyy#ffxizmnOyn&X-{a1uQ-;fYk zO_%+zM>jFJ?l<#BRIgNitf(y4J9bDL$OGR>`c$-pWDJPlfu9kQOyt2Fsd`Lci1ig1 zpW(b>RBcH_&SCTwm1U$WPyp@R!ZLAzp>CNH7)x!B2{Ez0jI|9vkhWKav>`(Mi*NrZ zv0o*WjUo8XPn_ZxEEyVX-7o#)6Mb2R;gwPvjDJ>PBVq&gIN^QiR}4g zgFMoWH3Y3wlQ(xR)*HinF@#mdhlWMLpHPcNQ?VWue8sOLNW zz>AvC3+h-@n zK))fPsd$5!bhY)C4*w2I5|crMn>>;#S69w^d)T$yZ%p301w0;r3#b7mw8#QBklV|N zRDW79SNs;{wmFa068C)1)BqL>6@pyRDN-B2+;C4RWOSC_cjOe3`02r6<%F@Syr_8 zLmIJ4CSHh}=Z?Q@r#Q|lZ$KF#l&mNZaU6zNZ4#SJdn(5)zSylKKVXJxY49tW;4Q|N z5lzldO5Wq+4~4s*JuQ4E7D3-r=qtGNKdSralVf zza*`APhRprT0%2dU0QuWCiRnUSa(I3_g!eyw4n7BiZnRtxB3P7buK)B8e;4%eE^kG z5t#p4l&k?c(D^XS>QgTNy504|zqR;A^QH+c?f!aS<q4Lo7KEeE0s|e>aFZ zT|y(bvoc&N=i)e){|uAOii_m}k29RuVQC{LX3Ymp;2oNrtKg=E(LE;;HD#{Gr|yrv zZOmRdE}{$_)=A^aV4X)TJ+u|)PwQd+$@y?LYQgFNnEGWJZdY7YTHTzRke`u(*6Tq@ z#+86p(53&s_1__8pv*e*&Fg&A=^1OWYxw5safw2uOZC#$3P&%b`K0v+D-Ca6FlfCG zERFAxWC=*T=!pG({)EcX3+ZUN)ikgljQ>Slsz9Y;p|ogt^FlBmuNaN(@N@X|lJn7P zgw1wm$h0)gefKEohffU{MMSaOR9r!Y$2E{RD*BK1*y|FN78k0b1wJ^~+qJ^dKAg|e#DSR7dTm~M*EUQcLmFkCBqqOux(q+ zoWiw;OlCb_RH``E7qR{@23`je+mV;&aY?S-`_|zHeUJPJOGIAz3slWs7@jC1$l$O8 z+>}BL41VXV%Wh1P@z6lA4i(cLqy z3lmI8Ka??9uV;>pREUD(I0o9#)+KUfFeyw=JyMmgdEXnUuUwDe(sNK)BEyH>wN`m9 z&>1r5yr@LYhyoHdj<2dFOr2)KXhEEX50UiwMP(-&rh1R@KFE}IW(c4X;+(7Dv#hdOD56#>-9y*XlNX(_24=# zbpW3H74zn;3g3g`;Q-rM${-1vw*Kr{t?j%>M4d|!A$VUyI#ONikLcgF!e`MhCKgjF zCqn6nBrBf~%PaP39s8$*G9m2fx-e!cB4#yTSyCjgjAl|l)KRIocd}?2t&4%G=Zm#* zJZM%g%eHzFGwX;aB2&jlRSm8Y$-#+pfyLYf+?nJQTmFbSkApFqS*7&H%YNfN^cW&TYV3HXhJY zgHHx&qah)IdjSFy;7-*C%xg>QUP0px>SeZ^ly4w_ppCqoDZ^*foz(+@e$~~LVQs%p zX)3c|a*IDV+svm)s7|S801s@--|ru0LQd%eH*roWPXtyx99Z;TuNXJOG8g3T&iro& z5Q4w% zLqvfTgJ3!c?tEPvC~|)WxiX4Cz}znol%INFO>=%&^z&sMbf;uU+?BsF*ns?MQV>4x zh`vEsRO@gjvW3b#!96#JLHj3#)jh zGx7s32~Bu<2667Q2hDPXn@AaXJTF!uL-^XsDg4)ldjTOC6v;GpNM1nc3N)uSqJ;6@ zwVT#n0It>-w{5R!KyeOih5L@?tKanx46MIN{9Yes+eX4zI60q&UhDhJ<7fRE>I#cP z2DdM$EXAzHwUuZgADE~t#t^10`gW#tVQ6h1uu>K^Bl}Q-v`RuPVZ$9*gv4w)ro)$a z1k7OC-SX2y77h97pqb-};N_J|j5%X|np6tdeeKNFF2B`=z2c~r0*e(ovLuSH#?^F1 zK@Sf>;MwfK&*|F-k2xP=A(rN1#f5bVEtAaf`AI{fC;Kcqi zrV|7vnuA+E?xQkmjct7%Tl`&eZzG7kF|Lq9*rPfxRSgD3mJr6Jxkk!K5a)ney}1i! zIa98e0h}LuH6`{M?0E9vcIUp`1DX~}^}O3ik}81emhl0HbXqltxu)4T z7z;d}t#UP4T@66qbq-VgwKT#0Hif+v(STZY0XGsz3ExZYY1E>#S?4a+oksD3ESq992#Ntw-9~^I2u7^Zeh^&>q=-ST zeIvb1jO&efVPmw7Gm5tDj95xjd^_wkf5nm(a~5l)4b$w&{V`J-ts|Fvmp8D@4QF)n z?19IQ^XAt|eX)kA9vfRQ^Z;35$Kr-*Ucrp0!yi6bhCyR_zS`|dqkB<=g8V4=Y&kzxP#x$EhQYu?vLsh@@6qPULlyG0t(Szl4!~3{!NCTWWDL(uK7+!YBi()7ViC8>{-Su0Aw}7S0R3W;{mJ!|ZE-f* zs@aFqA}8~>)L-`L4DAYLlk?R?m(4mAZ|s-uZCzRnz%B@Au{rl|a-6e1yR@a+jq)4WHw~UDR9K6&Tbyr(P$=RtdUNdMe;zN^`Oa_Ba?6UU5mMg{9hb79%V~D;JA_! zbxKt@4zR;wamgP&{k&H2#n|HNk3a6ShEy55?-`%84N}0iwIa(qdUV1pfRf6K)I;`k zH9&>$K*O?$A+LucY888jt?3&290As^GF*(}xgjAuSGj4f)uIQk8YxC)xM`Ll%VJG0 zqk@0d%Y!@~$lEbup)U_@dzhxD#&qk_g0i(^^^FTRXF&X-1ETx(xDWdE4!f%_%wTM6 zy>#K@cz5Vv@^?!;u(Br<*h6tYK`5QG*q_d{fxYd<8`-|&X6=j8ybmi7TMA89@%nNW zC!*34SOd<2Ykk0#F$|GQoQ zIae)J#JjkYe@VktP+3Obu;08Khiz%555A&fD`WC1=t! z^(%!NAn$SHqtb;(o0Df`nmqCK`+{C=*+27VkE=4=kNBLNLtYC`(a3@ZQ@dsBIYgUx zUDaI5k0nwtkIx^)5x}Ia|cT&q?!m$JA&&ya94ZbMepvN`2PMaq}o1^DQ)PSe%2^oWF53J_nGmOXc zXn`t|R<#|>q0%>_UPL@yz?BPlX7-occ+g(K@LbHfRqtVwj1xq=yl?M`b4Vv!c`zPV zP`7${x^Dwaizs=9X0A2!Iq8$6OJj}{#0qg7ur(Okc7DFX<%fs%O0o3}bjKZ?c|<0$ z2xRu~zx-*d5@zsM6KY~@SVy_#rcX*9g8#*dGji;RKtf9l_j)M~w}++un~*dUUGM)c zIl9)>h`SXea>ljkYa3*@mxdW)R&}20q!JBG=4F8g#4-F0rmzl{M`b>qsc? zgZ`|`F|;$0;=GMc1);dd-&7pl z9cd2Kde$Luhep_#i(*{yupr=hzCOjEbuun-_+FDgVYSB3>|{i%=Y}5Z!}N`V-fQ3S zn(tIp=XyhxgnJ2PZKv!7$t+`_d+Rh?zx`P}7?fPPE%_X_ZC{@bZ>8kPyIvujgrwB- z66r|$GR{_Lpr-u$>O6cJ4D9tvM1W78CE3*oZk={0@h$m2 zQ&96L^Tg@6h&-vO^9~E*oh2}-Z*Y8ngZCQV7sI|jDGvr5v+HIzFR@A*ERoSr$QFD$GMx7 zVzXOryW2;xwH>9HKGa+*jp5mH%&EIP2zcRq9zyYvEC(;&s(!N5>Nx%e?`a5iQvTt7 zPvr(dtN%&FTl+agNwa9pIRmpHa`<<`>*Mf8fxh00g^z6p?LV`g_B>xt)et)bj0%A9 zhfrK7uzr=GYl!@&W8XuZzlsuCr{Hy%ZS|a>|6orwaGy*Oy2#_o;^8oy%&4G z4&7M*`PDftff=w5?*7V(yYe!c20O}AO6&GBq2g1;o%g^Uu6tsp*q1QO6N3lFxPB{| zMo3kMli4c041f{KeM^;Sw@zn_=++<cnS}E}GWoYjQ>v7Fndf7`>gFhrd%O5ju<6 z*NqL8O{p>ZXPP{gKIrqyqjDpmcTRB89Fu0M21qxyD&F(;`Xd6#@h)#b!uo1TZ~MFF z4}b1cM-@+ZjW;ZB@>%!jTxj2I54#sx!-XPRa?zfWM_7n-(QlIo-Ht`y?L!|)h!L<= zLq+NuPEeK3wqGN{ZE1PF5Tz)V>o4VEJ8cCQjA`~ZMTVHgq0a`3SA~cblCl07CuHC! zi6j)~8tJfq?5~cuDISk3)YM_r$IH&t>~XL1>J%lSTjrOxai}=vB{49FV;!=-3SyK2 zgqRC^pReFdyOULU>!bL`Fq-AR9uejgddUjE=gN6@hn@)e-(22Zmc>c+D(7xeFp-imA9fOMjs03)T`%4(h|X5nU(XC*_>)v8m>0F^!6`su98vvS^eM4xd-E<}9`UJpu8 zqXBXcpRYs@igw2sA-}@1!;vIaJQ#x!$p-Vn`JTdoSP3gffmo=PlTzomQs!PuV6Tw4 zZ_Kn9@ZiFU$I?NN1?+QE6EJYVP~8?*5l^upJUrbg5qVzH;>NMcW;18 zx1eg3=pd<>ee-QVO25^r>iB1~XN5DqMfX(P4D-I?bZXj(%N^*XsYBffI)bt<(|pbc z2(lLk(zPVtp(HncztcGmY)SBb-;m7!$bi(%7M8ljfdb6jexkB)Mw75;X@AQFiqM~CHG+ut~PhYg61*5GOrxL4_n zQBE-s3B7*X;H@xc8ia2yq(KqtV``1Sdv%DIwv}h&&0El(oncw?_0#jHRNzwZ=LoP<;HQ&!p>oaNYtcJgF8pESg z5@CR0KXY2UL}eF(zoxfqhx8;w;!Qiu=6(lD!2Yf=IB9Qa#2J~PHol88K6-ZZQ5+xj zySpa6iGgNVtuWiqME+7bpF(P(W+rE1BY7Rz8f$Y;dQz(8AbNRQ&U3I};g5x~zCjY6mZLxq&bJuE0uT*zQowkgIh z6Sk>W7%UNd=LA#uOT51=S&5n+&yQc?_BoHjvAETAh^c2n+j{DO`oG&zg#)w=6wK_| z%u*=h16oqR{hmE3i(NhNjegR|N-C^SKG$5kXZLkI$G!+hY~fGzZR;9uT!?B^XF#5q z)2r5LrS7PVs0At8**?#`z8XS7an=0IdD|UDBSL~bHvWqdW|M4WOtTK6bAghKY{`$+ zs_0r6Qr;r{RZ`F0XQse@@;_hM=u(jTZeldog=&>_sHh8O@}%Qh{o~A#!Qx_|c{Wc^ zOE=ce!6VEyhE`vz;|P@|of4>CSn$n%5K$VC4z9c=TFf|rAI3l4D$n1ndn(sa#%&H@ z&)FLOJeH=IYq`U1U5Vn;dHL)BV&|Blwdk3;Ikn4R~z{@Zq^pouF}oya8w{oLs~s%<8dg+wes@~hsNYTOlZG* z9ebocYNmt>F&5rXP*9jwUSamlwRW)LQjbA?LMaq9L-^7@=3tmxU-=oD{mB=c`iMWi zK1v9B?+F5C%!j%ip9hDLJvrWZBGDJ-d^VddiPp5)sbyF5_rRgu+IL}I=`X6KNnT+5 zePYOa*@=vg-1&B(wXxaPt#Hp@Ul%>f{Ki6uXFgz|x+*}T)}lRxVT-MPT6LguN@L`* z;CpXcn+O5U`dDK^hXH5IX_>rX`c7I7x=pjt2;_riaT&U&Tob-!z6l9`>K zwbON3pZyNFPzp$8SYng%oz45EpQ`DKjc?me+~N?c%cIYtPL6GKI@`#Y0fX^pW_VaYXgg} z8{{0KhR%mzO0$es6k>zqr8Y=-$id@>LilX4hkcPc(zlv+;g(51AIAca(6Y_AxhebT zOHU$-0Ks4XgCVqBb@ZzT6hjziF&%ES*=y45(yR{)x z=^6y>-*>zo%r}d93;^Yzgo!$}(qc6t@a!GiG-~&9LLd%HC%WG@8Ww-hLJ$r`i~ztN zp8@@c8IW$BX-vE$`r+M`1&hYQj>QJYQR*pWy1L@ay&eU~ExvJ9tzYGa9X~&1A4@v4 zR`#pYnFgoLD{JRP{P{y*-`;K4ZX#Pd7@2(jFa3dJ;t*_3V>p7a^y{{z#R8xh@aVm9 z7mrz?b3U`TeoG{SE~%J ze{GFf!=yPoGtS_+cZOBAb$PwLCIBrKxYcptH)%-)bkF=8rww^c!~T;$tF-z2Qq4(U zcaI-ckU0G6mS91u?!+YY{c-irGY>{&)j5q(?+(RWHwXT4`@#nuWY2Dze8{LO8yK_b z5R)@wTE8&EnO8}pM9)DsK0Mf&)RRr_Bj?4ttLo#e#pgXX;`rn+D52+5xh1UW9~%Xr zih);d5AGbE05mSQdXu>fW5XUOTxUTnAd6DGNyT7cm?EpA)q;bi9$GfS_|7yuUT+@D z6yfv|nk%l4Uo`OV z$tPV>_0Uco#SP*D@Lx&1%EmQM8Z`_K1W>C-C!K4pf3M}S*yrL+pZ%dh4SLbbEGj4w`mqcPV&@W2*Sd(u#{(>(!@6`DK&FdXSpztC+L4F_ zz8U1%y_}(O#^TQR0L;{6d=57Cv9-kyrR(MZ(I^~a@Jjep*SG}W9tDmAae=_QQRl>4 zPD08EPDa7eho+G*BWMmp^cwSd0W&}g>?5j?7id{8WhBZ115!IcdQbB-)WxHDZM7?* z{?eK)o7R{S z8?kxVlET&UX&_dNXZWBhDjm9rE?O$KbAefq!B{-(vo`aFqklbKR7fH$Z7?1Nr``^Dv0`mo3_Ml z&ivlt70au;WBqzfVd@S8xy@{PA@}3QNZtbcNitysjwH#g{-xt%@~vJ8)t7B)@T6@7 zd0qgvHP{!u$H!=D%3UrXu z(kw%)UfxyneG z$8@74lJfZDQ(q^&HJ-Cmy1}D$O3G1=)|1wAaeJfvTZfbD8q@7+V|7k;kGH(YaMegF z8l5HmByLfq_Lz0#%SV?sgBUJY?m#w zAYsL_fLuGO$%3N9lv!yMpPdRZ8S1s{#7|1^!i6TuUv@OfubGvrRr>D6DO!F%&b%2K zWZZDOyggj6_GMeON3dyKqq`&e5AKN8eY{iyW{$|*yC!()biRQZ&Ohg#OMX=N&VC-b zYm5Xsj?bL-AV-@?*!F2J4P>E=@p@Xblg%ZjHT8jfbm4BN9d5=LmB?kW3<`K6&#O-i zJDel8o1tuqODG?lu)7jzZ*>eMJ%bAHgEr50h~6VKrF#?5g*D!KoZunb*NG|7n>hwD z?Cu99(QWFaJJZHLmmpPa_I&0}Z;Tw$e|VO%2HmPpz_P>QG^Z}zGrta`w@cyS3p@3H z2-sr~DY1$2q|LySBkbq416ML(Zv>(oD0F5pM}}&@@obF#T&I)YLCJDD{GnMF@U%t{ zcBI)=SMDgc;~<-u>N5@RbT}aPCsP(uo|5gW4jeX;_{f1mE>lhQ2XBTzcuF}sI2@5? z3&fHerH48&UVAhfr)BDpE+JaZZyB{_5o4Ki{flh@X+2qR*q=iO@-~L%+yBTzKnVBh zEmns-$5EI{5DqHpYva*|Wa-4|Z_mltcU%(4o4Ul^Mk1x`tAOj~D)v2Jm=3m^Mj|a>benxT-r+$%` zq>IGaA|#>;^=;G(H9OuW88t7&BpUg5A**CMr5xIE5mm<}wTXWK^ZvU3Lh57-^ zg+lMJ2t?AVS83e?STebH?koY3r~Co}_zw;Y(ou1vMGNLP=2Ds61_gDh9Z~KmeeFeX z;qas`VUJY=hPp;ASF9tPD67=BUB zsy|nE2bIHJ;s5QrGR- zteoEUMDXuQc>&(wV`=+}zVoBsw}MQDQn^y_hg^M{9jviZ|x1E6D+SR#soHJZ|)={>feD}?fzZwM~(=&72kT#N)bAHc7~7^8{6pU=MN<` z=jR0I&qgc4QxQrk6;_{VT5I0Ds#If|TrF1xb2xr2=V|lu_f+pr;kXjVScK0N(pEo9*??HN2DMF*_fz%>_8`de<{x-&y{<|?3OT5i~1SEjD6?ZMz6 zlAN_rI5aidI|XSjCZIeGhw((9yN}4Pn+E}c%^NUR9{n0BMU#dLtq6P?aAOC2o=+4 zN=`YGCtE~8ra6|FWqwMb==O{{;4w?bjP(a;wqoOFT*y`Uw`I5Y8r>MZfw|TC1N8AU zDxGyp5uoTDi++RMKd5dk#4v4#y`ScHl=A*BMo<_83ByQB=xF>mqxl4}xu{)%T7ydW zZGvB@`{oO&lC=jr-QL@7H`B1pM}N9eCrkEy_+}gw@v3B_T7!r8r?vNnyP5ku>B>ET zD*;>?0i!JIMs;)sOc8)WFsV+RNH^Nf&wygMy8QCQI?f%Kv)NwqwFg)g=sxc`y3j#F z3b){X^G;lRufvT3ea(z&>jl}B^h>lu$2Rlw5+vjBZYj9=`N*>OWuFwzwviU`ntZ@^w$`k^1h5|sWAUMSlok?DAPGfwJ1pbZa6X0VzE1*O;_j8qfB&`A z&Bf=rAer!CMeSuA`bFC%KBxL-44vCdkOl+7wRJts`MHv!rp>)ZH>A&@45S#b-)c-DaoJM6EQo4{l3Ul;ch!JW zOgH(I6FKt>L~HPiZDecHy%A^3(j9@IP(F?lwjKC7sQ{;l=LhK*HYykI!ihfaj{3B}s@K37W3Xi|P%8vOz=!6^SMaMV= zZVzz=Up>Q-5KS~H5IVkmiMvW=aZu>&UmgKOU5bDcEAYtc*_5#aizBwm6VI5|UXgLw z^ee)c(sUGt>L|IGQ9VV!s4T>(smB*i{vy!pjYuO! zpJS-Vpy^YUG73PycZFW39U<+UhwFK9Ljqo5L&+a0kt^k;3Nz6E*(EIcE>A_SkcOCd z?ai)Gt>njCHEGe0oZ**b2BdhzhYp;XlNeFy-#*wWQ6auAxKxTz-%p05i<@8Hh@C7h zxFkc1T`1_6CL-cYFk^2p58<%mNLq|J${YS)B{aF*ybyaK4p*GI_XD#*0u5FsE3;wM(-otAo_gKV&sACfH&6Bm_hwprDLX6*c>S+8|?5 z3rR6FR%=YAM0NFEla;1({dZEEm*J)1`Cnu;*LO;x_JCLwBvjg1rF!=h*TNLGOG6u2 z!oZfyl}>(YJFUY`$~T?c$E!C3O;NE!FNN-hk=R9_U6ax}>ffJj=F_^E!wV7cnk`d5onLxYotw!ns?vH} zBj@4vOvq<+h%h{b(|tv{NH)9jydrb8-PqM?OjBHl*g&YtybkTQJE7w%P3r>Kzp6H` zuWbhoZweR;x3>o>op48ds=9%Tlen_s&)<=PJotSxam#a3L?`ZUrsR+;sk8Sjb8M_1 zn4cvgjMg(h@!g2y@LJ=9cwZSmJl!g?gtdNM15cvWYJi5``#cx>p|aEF2-JqUs9=b1 zpl$aSNg17@<`IiPQ>5p~^ZVwMPFPT0=-5}*UzCWtvaif=`oxF2Q3(VU_n2@XQ#bQA zWsF1Kz3&TJHt2BJqcj~}4m;S5gg#L)j;UaXDv5c6gC5e@I5Vl1o8Gk{5m-DYUPu8wEc@ffMPBkf zi-a!l>Fb2w*yfs9q|hzp=)$)p8QUAxyx?5m{|3{-w%=rX07vHnaJ zpBXuem7}IdhlyVewkBESW9cej2eWhGfz#2@e9_8!WP8krO7#*iCG6gk})8?DK z36Z=7;ZC_+o1XJTg3Znm6mkFo=Fq{rV@*linCaS6p(Y@gvcWIfb0tYS*UZHDE;PKk zlrE6|&l zkg}}(LUK9fllV~U@9!T9#PSx^ge@g5-sUc4z+rf@_& zeSJwr>0j!4wTrV6!;Cj8thz2jgp`%-n`-}r2-&E=Nhd*y%x(tq*Uq{Gm-*GqrC*OK zU*B>{n$H&1S)3h?bvf?tl!}Ynw57Q2U^1L9CVIqM^g-o-TMKwj%x8&l3Q^#sKrX`P z%uCB?lz~y#azar?jbV*uPPv@8s0Dhci974XdD!hKeCUo(i{#~sKnh$y3O6LZZaWjt z!|-fxhAEaEd&{1U?C7ri4c85y?8e2<=1+dz$T)j=1um6yxF7X< zqzkRxcnfPMIVDsEg$JQA!=2D$p>Ym|QrvMX7g~K_QZz%m$DR7Y!sn31H(~RJQil!* z2RDM7@9x<+JNNBYPas0@1BWxf5HqK;xF<^gCzTP`^{XX(u3N+(7qGBD2%YW^l~x~Z z9PJO1GA!*ZWCr(?AD*Uwg>PB({bRSM*68XLdkHLlG9jc5mt;z|JggIId0&$8@oi>5 z58emOpHjnP(64tVu&wf&vyp^@)Y)ve%Qi2!F)L0YLtX3x)8X`+b$8q$H!NvXPR5C~ z+#k(LygD#1d-7{tIG=8bG3eCV#Zw>}uhV_89GDPrr`wxmw6<4Ll0hIOAKiD%$Dw+? z#qcT(J3?CXt0N_IB)d8d_@kL)J+}Aq*V(kUFezf_2(5vrG2W@|l_V7=&5UjhVmJkNxxi_AS4LDl#bEDQ=N*sgyjP|(>~2z^ zR)p$?$6<~=)a(u^ZzG;N=IRj^P+iAS!h6XpqH#?lKm1cbiF%y9lCu>Dp*3lC z>xN-#is7#XhL(jsVPB7&1kg}8Cl5d|Z(+Nb1pGK?UdSM%nBTT>z;mhn-hfj1(Ji;6WvdGAMU97jHRJj{zrdZddwcM zHYH}QIALz9oZQ1iG#JTFR%adoCCkhoYC;=H&N_7QL1lt04DbMr^JU;yx5;D%-5G~% z5wt{4^+@TS-TGLHiR>#En?@F_aQvs~WS7(i#xXlgxKhd*+d@50Jir#8B>b}AExoBduAHwp5IdN0&!#gv+sW|iK2>*igQ9d%$(uA9lo zA)RM1pH#aNSS6FbeF?)##JN*2f!MU{O&U|?OE2P1JQwg6vP42hX5b_~@9>e~eT$_~ zV1_hs?A)B9CD#>U`?WQAEJ`$+rA}wSL?qq!I@M}r4Ia&xx@~JSR4ub`5O9GfAX$-o zygkZB_k^{r?AUopZ@@43fFsj3N5GlI>}mWhxsMw!|NP}fG&jSvfKMio^yv%RQaQ&^ z1_qczqmkSMDI*WJ`Gk>n;vNH|Y8s_A@gEo6#Q7yaf2y^C*dX{13o)OAm~|ZF<*ya| z9~n%;hrdh0e*gG_6lDLzdjMZK$o(I5!irCGS+rT8b>W+pusxHm!n=4c`}7`R4dqPj zmsbd6Z*e5QAJ0}6O$_;HyPQsHuX60Fc~Ek3wx83`r$;tmVmZaY%+mz%3Wh6?}PS6GLJMz3om2Q z=HTGu`vz*V1uuc>NbSgR5^Xi zUv=Es=2(3%2*y)BsJ~U^RJ^&w`*dsr$#R@8{+VP>H7?N|Ay+#V+hUH^>y(#=nQ--P zmf&JiS;iA8Ux}a|uU!2hs$F9y;ee zMShF5&w6RL(9+Axh^^Kl1q%6Px`%J$Y@WHqE@zmo%fNlaBGr(4B%u9II*#v58DV27U_X0}*g zh9REf7$;Q8Jw0aPnDHfYjb% zh+rTiognZn=Kb;wXCZrAhyKqt*Db%83j%KcvN{2aA^1#b4FrmC*inbDjBsj0 z!eoytNzzV@&oQ73q1o!4EO}hZ)N#xrz?UE}vPMdOJY}(SIh`q(@Seq6En?ZS(AkY3 zK3D?vE&{El@APPf6Oa7$4JQI&8V)Oyh7|K>uTRBVt{=VyZ;QqJ>AR8SfeEo#wdY3Q zVu_6yVeic?3_AvDtVh}oZtl6m2sPT~Gk;lxpTsr#&F9d$0y9R^fHZ|*J;GNldgh4S zV|apxM;{uQ|NLF$y>gTeOgTyFsXd|Y*2TT+-ty6A9=cC4cr$qzUyilz^kQ$Lxle~P zCY_6|V3_fd0CUpiZ0kkgIOCZJGL!!Bh6g_=QNLSEzPN{xe9$v7X@YQl7iOKKlMGlKq6YesXgVA|n$+~EAu+Mw&5uF;@NqA@|3;d9Ze0~cHWNaG_C<|MCY`Naj^ zgqkmTPwKX0)o#s6>RXG04Z$lL!woqWoE~MP3*vsJq>)cI;7{8Jr z*vR~yewFvCjtvpe%>fmBIgN(X8gSMNxpwdMxl^JK9B{EdO!q8ha#2Z0b?ia6TyByX z${0zm+c;7tPyQlpcy}+u#$i8O@gBXiJ*sQrs=~~hY_&VSK7*Az6b_&5i%JZmnv7l+j680To!lUSz9Qa6#_hNguY6rb#`<)WbHiDcSDACOf(5a-++=+V5sCOq7ex z@%1B2f`TaDEvc5K{*)3dv{O{?AAeL&5bBH08on2W)*o{H*HZR}{ZWKO~K9sF+b=XP=(1ym7e`6dcXB zW`DTA`1He%u`aQ1ua7T8e^nu%Cjgb#qzo4aS>}xwJ-Neg92?XN!vY})dm9eScopT zd$tgiFAR?b5vREwoHQVf_z&N*kuARi<=H!HQ@7h4SF&>P~!DI~-hi zWyq!4c$Hai!(4Uvr1@@1rTI8B+ykIBR+&sH9U_JLN3u+>0snVqUZ3u&w3v;n=!E)$?o+ z3LO2zzI28Q1847EikDRa>JRtPUk1>Hu5ptI+wuwv4SV*0kc^@&xfZ8g+wV?wP3@7d!m&~9KgSJHRUedGf|6IKACxz3V#`T4OVlrb(Y z^!sxVrBh3j&(~0`&0NH%!DQZOq=1EZ-8a=+f)$o)5E!6SE_iLXlp&>GUm`mgZOE^M z4p*n3;8icy#Vt-Ux(|rDOFSN3F=!u1LYEhPy}p!CIqHU8;NwVa73G$wB#7k}W!6Xz zjR}S6dVJ;*kEhkYjV7}gu=MVSpNj9kiVLhz`d)2n&oxDj1tVdU>W-N~r#f84kWfJ~ z0=EN8=nR>KjVoHVr&CtX77HF@wKlD8ZMb(*=6{9N;{%JoO5_Zb%G+1hHfF)>UyZ10 zA7s%R@`@)Mno}#`Qq6+mes;)XIe@qU8TjwGj|&P=d;>A`tH$8DT<&AZ!DK1`+X@N( z&^8fDMai*ur~}vK0fO@j@pDTF;cWl1Cc&1wTERJbds|Q04EA>cHflhl1{UOlNuxVZ zg0Ns>%1sV*FR&RZov+VklhG~BJ{{z)zz`QyCZ<)BS(>$!s)_f(@YKFxqKt4mP=V#C zuKbd-9AJjjZU+h%qA3~(7P0=}0?Ho(7#TSIUifjR&yLL}J7J|)YlVD2;t`daR@@ha zzLrFUO_kV77~So>TPZSnZ3q)z-OC$qiaN3^aj3CLZUl@b`oQBKl7n^mTc-op^|u&6 z?ld||@V4c8kQdaQJqMNA1P{E1XI?J-51}veEzT2&7wRRcJ@Z8gws4=pe{+zC#Ge5i z+n$kf5kJQo23k2#l95`@w+A5R6RLmaZ8a*N_93e_53pIQEkQZ@42^@Os`6ywuY@@sOn`Gf)(2oRv zxLDQ=L;jMP{^)uUpt3@aKdwT2`(){dje2pY^&S0_1_!6-%f36G6S{rXII-hCOnQZa zeP`RNf1bque6b;G6>N>I25(y#)^&9(5R^Li@?)>S^~`ELtZsQBAWxz!w^Gz2L|$3i z1&~*8^Cz<=b_{LT&S!omkKF4%c>`<>4QhR+AYeZJ8`HDSErg5Gc};$iscoQaw8#`M z(jz=B1Znbs?WpP96L8CP(-g(?o)~L%!gBS4m&1(8mQ+~I{_g&R8azo)EwZ`u8^&a3x`8?ACaS`2nuW5_C!W+;boQpLP@+q^v!D8tB-;AC|P6gcZN+L&S6LzJZ1R3?Ch13&lC`CNqWu( zupz+_d97Ofq`luq?LuZZ95sQ5?T^LW3kT>;C+$u%f8ir?t66Jx)f;~Gl;4bG+4WBA z`Pa3pl^h;mM6wpy))3n3zBdNkTt4z*k9T@g`U1@5uPDOB?i6Ri%o*tO(UxY{(t!3>6JxD}e5oDls)BC5(>DODWm427QuzAv9-LRQswjTRx z1n%bCG&HOiL5LqCp~GidOng(EmA8&YAq`WS|6Dw6(=r&| zlld(pwIkXS>iaK+ZyQB91!^6$BH>E%&{p@GI$$CM#$XmY4Q4`n0asb=c4|kU zsv1yfE=}J3xrVeWj{N!OpIdv`BgV+{*@>J_ju@ZjQ|Xqn6$0G?_F)df(pl3OwvmZXTp+yXRXw+$O)X6z6CFmbRZe?& zl$LNm{u)D=#=Jukgtcu1#Nsnhk|n^x1pd!CK5ASjU@*$bi~h;Mjywh~yvNyX#CxVl zQf*nYWMiU!$pP--4^Kl$6?i*>eKW^{%r4o)MiO*b==yN>XbI{z#)6hIx{P$MIM&Xn zq*R8N!rT_uzaU4&(3s>@XPMloz*0P*8<-!}slyuMNM%WX>dT%&H96BWSC}6z$}Qw; z&m6TG_L82^xnkFK;4b7|`3P(48*ml0+G>1sN{2D@yXNjUlmvus-hMry1Ga$Nw|&E* z4#^t4<)Yg*Tz z8gN4RXUcp1wOnz?jfVhE^T=W?P^d=#FLM`IYeoNVDDpcJdXpCW&)5F@gmvm1ou%q8 znnEFeI{Ul~4BMeUo(qb?xjcrrqPedrbMY3v@e-_2Pt*QZ(QeHB-&WCX*fnlq1OeCg z{U`MX9aDd|4F?`b$heRd?H9|NaX>Cgmzkc;*!xi>1M2}NgdYFbde$|__|)*)S;&Z)0i?5&a8AS z_Umr9$(2r5Ugw}=Z9RP3r!@{;x>QskFE=W^c7HIj>$(LL!;=AcSL7Vn?_C**@R0;_ ziHsTGhKVEy@a*P#%-<)Cny`SnpB0PQOkJHTBcxZHCdfvAlZI27-|@EijaQA&c>u58 z`LK5-m)jgOz+U{V;X}1`l!;U0@G%g;28nMwMxX62l+anJVRXbjL3yi2g)?uPYq{Mr zM~8%n-W{N8JrKt*=l7T)_M%3}%Bp`-5@s`WVh|gXiZ7ESUUBnhcs_xNN2hLJ#yr2D zQI1lL?kz)p>T*x9FSKjy*L-?i?v*YmyX^Gz6zD8kaE-Xx$|2A9>#(+)60FPtbTja} zs(fe!Zw8EAQT2Yd%9TNtRkEC9N>=#pNYY|4{j;p!Jj((bSvU%NCfm0JyD%}R6E{;z zj=uOI?$4EMLyN4h0^SlBd^dF1EgarjotCuVjTH~i7ZMT&Xx_r!e!hmUL(SuqmBpF! zl%M%NX&n*OSuJ-**-=mv6jdWvIZh}c{hGFE;-2ZZK(bos+FHVn6Uz`8F% z2=?!ZvA&QWsx$umL$nim=IT2~W2Dx+hg+sIZQg=K3<+;}G@n()`&sd}GEswiI2Ex(f|b$6fTxbP zcvibGdz!S$LI`>bJO>L$NmMBU|3ANIiF$-ELH@gTX7*Ft7aP(=IwS~k}qHYONsG)1pq=IS$!p)C@~q8W-vomwJ@KN z65#rqOSLjF`-@Cf=U&4Ba~kKr!yjp@PMb`HpvFOKF@bA-GWv8)yN+DmGFYrGDUpN~ z*z<4MeT!w={3@J81lNz@11=P2^Ge)ClZOW(G4b7KeTr{L ziHFpbQMIu<1Q|Vd?bPdtgB%WMDMJ$rjxoPHn~hbl8Z~&LwU1aM5>V5q+*@v+;OnaC zZqZ~P-PjH%P{!SQD)KWAPiKluAUCi%OK_Q5^~R>AQmy_fE!*k(!$VV;sw1g` z<^_I@79y~y?hq7z%a9LzUGNtTYI-`IJC=Rwa#G;Ko#7V@?s05vpJab6c~M8{0**t& zf$qk9{&dW#^>}XGNU`iQ%@8p=3fsfOm_d=@5lxYiI>IXesTP)(#OA{k(;QX(i{?rJ z$#AcIi&53#Jr1qcsLb|#g@vG{ zBcPwmff{XmojJ&C&`X6_gwb-#!suCtKzea%1<=Q8B|rvZ${cVwJiRgjD@9sLvSLkq zq!o-v>=~S^zU^qCXee$2Z*7AzzU*fnpsERv0#`t5TVCYa=Q{!oOPq~|gAkKV9_9(! z;6_c<=w4E;m$A0<@L%Y761rs=0(vLLva|AcgHJ{_Vy?v#Pi`av9L7+mY5WfumUVym z3OZR0zB9Osyzy!wGz?^uoxEftzZjEmda_r_Q;}_!%r4i5%wUjycWFb-R*Gr7C>e&> zXS5QZhWA<2U?l$1V(2}S%YjKifYJFaH_co|eEU&pKZjdnp0mute)1ix_ZgpSMl@U` zv;Akx#WV$E69|io)HB$WyXJZsp0oog^*Q-&oTK5vQRnpL%c5_hE24%|c>0=fs37 zcWe3L_0&DmtMcl^%}3Q(MKpWbT(CAKb|zxEtaoID11Cg+`^i1#aKKG8Ms2oyipWdQ zJ6Z}2D)@XM!d}hU>eE898>@OZ+L<^I!Fu0EOP^ac7TIv~hT6${Xuius3N2xD)bJe% zw4lzwd`Gfp5?X@N_ztE>Mk3aa7LnQ6vzra{D*2*d?6HK*9uK+K57?}vKd;}YjqP8q z+({1pho;8EDfopjQjT)NEtxCPe=v#DTJkh6M|3PZPuqVDv>fl7yn0Y$0es!)MPDS3 z5RDp)`su!bMwEk~=EqyxY)_=XW1MPCu?|^l81Z*TY1a5YpK_MM$Ml2&6#CLd!uMtu ztox^On@6ptG!T?iO5eFAh&!9L{{aG`bACj@vWOFPcPHGA$rlZS*6hcve%3!_2+!&@{)*0 zN@|wUv2nk4Le!zv-JH5-m_L4VU&#)KN~_JEySciqw%l-w_qcm|J=~a4xj}JKKch{? z%h_L_)PTaHgyent_8#6d;Yp@|=K|f_33K(V&{T+u%y149kDAAr#`dqkVLlK3oz;m7 zxkN*RZtD-myXik`knLA0^nMHN>cEHfS8N^+^xD>ZVrj1_f8ov5L`;sGMOZ)hmpfyC zg~#Hl^+#VX|0i%ru?>#&c`G3Pfp6`|=b;m6-Gfj!`kTd>BNTT z%3dl+JlTZtCUBH&)MI!B9|tx5+na}>ZL?gtmMRp z+tus+#M^d1wK7-h%&QGLWARoq6_P!hyB#im__Zy4a?jqjsUw9*M&mVL-*z7qJC6F@ z;{mVD&5=a0DCHp1dl=qZZLmxL_x7fkG#TTA4As&t!Shoub-SPAZMnHlV8n{ zxoH35zf`$$=ZxgfPFzmexNb~eLC*%BI;!{B^Z=S!mZP9E*q6wi?i5nRbV z_xUR)``S-_OEBz}7m?EEKPh@m(z_PU`qU2Ea+vB2%}qX(v+J-VF=B6PKawjC zq{AU0{0B;gr%Ou=9-fOq-gm<%Xw}XF#NmG4U4&0p>{|CH+7BmN&9_foHV$5$NsT8X zJglYep@=|zuDYaMr_9mwd7H}3Re9opbE;%+j4EN)CUN5<9hDJu{X;gDUx``JW8DYh_j+EnH+VN~g9 zPhIxSWnCX~4B9d0ICI&*|FIwR_BXHeCvGPy;~d<6y!AHW=N{%PmVgJJC zt9CLiGXsVrO_%!>N&zZ-E#f$wOsrPl*@urb^-UVa^EVj@NqN$dvuXJiuz+tipXSA- z7Gz8d%)tQNCJ`5-|0r3esguP-1w>HLYvJNr9VT;`*5$mj2#r20U!jrbIa$nYzsy#Yg0K%I(%l>U!Q zXZ_;R?yOgD32?t;bbN9dA$W!B>D4%ob*IH1`{bEDHn(T<$~oWI;a%P+!3Du=N`*ar z{kvh2bl$(h8eo3W45>>`FE4ExSddYTh|6e1)_p84!{u$h&*DOKNO_-TI$`zwxdPmw z-hqrwEOLLAzK(EcbJSOV)^kEZlh(FUI90^Cf{rAz#eawt!)0{;66kIboXT@1Jz2Zx z)9}<<+3=pDg37N&K>ah-)Oe}2^#z#8XdP<1(`WA|CcwK#LD3L+_)P=Dv*HY%kL7%x z*xnmxTKD&DJ1BVLZjiQ6jV1-vIZb8-))&3}6=^{-`t3TS*(^=Ljh8TC$IBN#cY?MC z_q#(e5y`9Jx{W#u=WSVjqP$)aHNz{s^*? zX*tcH<(Ba!vBxI#h(jnsO=wMB^mCuBU*`p>CHM}-^$t7plNOFk4gX|UO_?8S>Q;kr zLnf^<;3aH5m98VOPM@Qr4+d=a`p+qVitN5$U$D;+cvu8nMgB(pL8CW$stq| z^yN`VPGe#kIw|5f*>kQ>KrQi~nEhq*7=|}tgihsefnrqo@ zJNzGy`QJTZ#Zrt4=9;kgmpXl!n%M2zXn4y45c;8=o}_r^o`q_jT!MQLKDSe}f4ze`0V<-saE#6%wDAtsSV>P7TKj0 zVEt4%&6+__T#H7?0Iskn{+iO-qCGjP{cZ8k_I6KRnHjvO^yhcgC2!GX_QfeFt^X=9 zN1LMd38Axw7gs(aBRj;JzPpm{UaD1!Aj2nJQ<-xWU^zXMf2lBEJ)yJ8_+4U-l53cS z(bl(oF|iesXl<@~S}L=2sh~7DPOWbx(5=y$zeB-YwYQX~(!1Z@?=!uSX*v{$S47m^%hYLvnBQ$E z{K?e^+9bv+qn0wcXX0s!2z$K$++W;*^;Qxy2j2XrEY6Q?*h!Xe3CW=pJV8a1`Yg~K z{gVAz!)PB3zP)Yj|FSzNJD%E#JvTv!xLP<&uB%O~NHB_7hcYyOzwK#~PEH3s4S-q;?-2Lq?Pw*m z!HRtt6cpwF_v5w{CRwf(Uf(iq`QwZZ|Bgp&&OGj^-gBG#y0 zAu=tFqzvi;UMFvaoyv6=W}2z&x&K*lQwUKMmm19&fCxu!d`UfyGoNI1IH=qCQvm@| z1jTxxv*FQ! zsMFc252{%Bi2zk)t` zun(Aveoq0!(eTN}2*v+3-o^nFP8GqogpR_e)^j&?T)n3N>$%bCkpc*e(f`;MOMU2; z)$}9cLGiPmGA+Vn7^+TZ^%4|>PV+=HzpdWO+*82+MYRufXkay$uSLK?LozvmN9xXo z8r5SoIjOsw_qadUB|L53UeR?oLxMs_q>Pcf>lYkx&|mOz%mj_57ymJl zgCo9gIR>j}gy{al4i8krgg}5RZ7}#o*wH+*qQsJ0S{36q->iSRI?W#i^J?B~h1}RA z2l_o1UuxfzKyV>VrUcZ*5*8l_`R!ZJorR$fLP~u3X4P}myuK(2Y4c`KB3H6_kooUf z*iv^s$~V~BfT@*!{dV$hSSohv-$AxdF5jYA1wj9h~ zhGA+Tn*)}!#Hek}mecZsEm&_6w9WRVrsu!Bx=?Kiw4)z9YV-o;EH0)6)@l>-R1E8a z8&Kc#)%e%-1cI^7!Onj}X&%wH*9e!Ej`6~wJsTYpY}Js9n}iaitM2^zN2$$?h4n%Z zi&CQhTj~b-cNui*?xO|&uj&Te<3?Gf!<6Y)zl6SQC}gZ`PHPdy8C!w;kNA;Yt`)V< zMs!`ZBC?rd(BX&)Pc{RVIc;!&Ddo!sTjC2yI_sg!=(D+fcSBu`mvrTjEGT9i@MG@g zXV3b#52VF)y_I;2db@_|!?Q0&$wQi!r)${gi$+Gcer)*lKRw_p9Z8euUMof8w_?>0 z^W@ErB+EzZ0DIQ|-vDjjO_x9s4gst-Kp@jWN>?u}3-|gjMzjHAx*dv97|F=t`Atn8 zP{lKqvrBCG+e~!gz;V^qcfIik=ROgP6Hm1wxA82Z?d1Nrq|8AdJG$K81R7NVt&HJUmmX+E+zXd1;Y2jV2&lNH0^|bslFDqEMik3ZE2<6 z`F~b_{XCVY>>Lz)QFT39k4yRj$Rs3u>Yu)zja`HI<3=yL!f4&-xqnvm-4d_#fWxAg zQ*nsU>z1~;-pXiVFRtbGEYziS#;Agxz712$#WyT0jG5nhuIlUHpahuLX{h$MloaXt;;ElW=Cj7y zzuQuq_Og7(k9z~G3WD2fm!I}z5gDFtA?S;;Wt3y3ey$nV4dBR(z2Ve_-f-WOv2;C9 z1x0rjni^yjSet38_f>YnZN{=YRnR}p9fD8x*x8{VN6X6iUE{5!+j8NxJl(I1m5NAf zxjc?MoFT(=CdYqKw)AFL-^-rPt2P!P5667mS125q?RuiUp99*kw6CmWK!wt;><>u9kw zAm6qUsER)3bQE29u#&*+dvH`E&Ht}Pf z<<6KO_)+lJLK%;#hHt%BcT=cUum0f%ZxeM^pHc07GSX1r243qzc6@--6*?kmIkCNa zm{IePk|39N@*uCzNcGF&=JA*^;}MyFz{yFqswo)^HHydU)Zb?d`xo1PcCIaYZRywKK>GOH>^3$7*$ZGvwB;iN`$#SjMMGeY==UmtGmd1&j;bxRI{h5(K=a0N4tbO)XE&70~VWI>rqz~ zRx$g5VF!b^6A8E$jk(u3u_C_-FXZ(I1B1>@0n;MSA$^^tO7Aw|L*NR%M_PL8ea)Vt z#?s`TChY=V-;%ws-mA$FOWLLBucp-^8{S*po7n~n33x>cW0x&4rW?d#S#s6ekWMgs zeb<5Y_UwU95E?!}hq-UDv0bU0_G(ZKU>TlQ*hxuAJa;=eD63k~FR*Wy&^NH_aX^L8 zHI>f{^RmX25s>|2eCCAYPuX6}6IJ$pC945J!@f6Lr|h>-s=N6n?!~8@&AdahV~_oo zdP*R3OgPsZ%RW6V*wytMVNo3s@BW6}!;L4}SN>?jSN0dJt2-6NpsY&82K${XK_lmI z{s@LRqXcJnME$Z}By$L34$|IF*u>gzfoiWB7TC+SLy=c zH|>*eLn6+eLCQg!XvMzRk$Hk)u22hWv*NmqPv8wi){9o`fM8nh>R|5Tc%@A-t#fTP zPFYKLxy9Atc)7KZ5bodS%O5S$FPl=RhWx4Nj=Mv(X^ZKqohj&*JvKBEUZ<(Bw(ilR zLA8JQmCKyYT5{B4JPhGj#3@O!rh6XMc)jfsR#CGkU9X#y8S))ksCQ(ci<2`4{KXivG8m{P&$fH{>^J z$gH(QZ#P+;v3YiZh<`~0Gd(F8t)Q(G0ehf?03s#9%6KRy7KGZ0!woH*4fh{g@mIiA zUplmFc>ZSjZ8FNm& zXp?G2{t2!udckD^ftD7Nx0Kx;mka#zkB_d4Z!jgF39@!P7HR~jc>A*MqQZ_ToRtm{ zV$VpvbGk9L1U^G)6gufi4t#m)3}Q~BmoFUPm5{R~`FN_2J+@>A{;2VB*zUdkq5~mT z;4eH#+j!&XjicfAh5>f1n0|lZ96)Xd+!?*te+ua|(6^fad7x4CT>WR3W{vG@;G{10 z?CAs<8LOPScV7kIl47~lUTuou3+=+;i7{j=fln!kImrTig_?k@(c4;TpHGgu^uHqV zp3CVZD2Rw+s(SK3$yZ~a{aF3{NtBX9M{Jahqcl;){DlN$Oc=SG>8ABX@gz8elUjed z{Ef8LI&NC3{`}cW^&P*OZKj@p0kgu|_Lh=DPL#Hx9a*+0=0_KrShBaYWX+AxRbB>L zMwf^B03gEKE6cCS#>lr8qM<&z6Qed8SrYs6YujFq@jgGC!+ZRG{nD~J{bu-`Fs|5( z&-1CgHn}c51S{D}JR*GS-EEdxqgf*It&t<3K@zKRSmM?>kH??dv%9c@+D$Pd3F^VP zS_9E-d1L2!SvSVsJ;xc^LY=efSOnkt*T0&NO5umP4}DsmANve8SMu~esogdwi0g@o z;n|%=)zIqV!)~k(T<$qE@JhkYgN<%68M4@ z6#rxhnR?;Uzt9}BG5n6!jX&V3sutV2{!{>V_@Q9KGjyO`QgQm@6&dE5jiT+Rg~+ur zM}vbE5iu>qo*pi19ZVLYaBKEt48NYabPI338a)MXkh;9P?m_{la!6F(mRM zqPGr)`qH0Fz@#17;OEA+BHjoIzA31d|7MqJ<_<0z z+&BxF4TtO_KL>XpKDbVrCHOUY45BP%4v+6!A5*cP&yEd5OYV1qz77m5$&o}I1RNRM z*P`?GK9i}h7k#4yZ}x2yzT3U)71nW(*875J0yZVv6BXZE&M%}S_O@<*gYzCr4Nr{m(oiBL$x{CENEn>oF25bZQgLL=Hnj4Yn$y?ewQYOg zPC$W)PLEh+gjk;j6Wtr1kC+Z4oE5)3F5Y9TBr@C64Mg5B8@M3EaSBl`b44eV67jph zk~Gg$9PTg0Hm0GG=j%8^F1wvYzb{Y#pAwT2#p(=r6;thoZi;v)5$Uv5p=(GT;7bx* zf2ce4B({P9RxtbtseD1mb`~}u@K?3d&|4fdT-W=VifWEnT@pQ}Muk*PT$LmY#o`i={$EtZSYdy!2(av?`9sU=d^j%= zhSVj3#=+5GQJT_$MeGIhi3nokHDo02!GZ^ONPyrF+$DH$2=0Nx z-Q9vaK>`GKcPF?*aCdiix7nn-PxtA0f6Tn!HS?DXs;FI6&tCVsmwXEet)fBN0>W9Q zRA@wzt-0T$t(UE;XecHXP?PIF0tKmB5(N%#eA*R1`e+N0@?)JH8Om&DF7pg?&CB;u z=B4qU{$-*YTtCgO;aj4@+v_?|*C0jCb|!}!5V=7|HQJTVOCcEZ}SOzKK;Is!H+@=n{a7UQSxbTaLn7scrf4d1#n_Kr8aRz zL=-e%^F1ZF_cceq`R2Zir7Fx{rC6lKFI!an86&@?W=^fCsTh;3N@?D7zq*!AaY0rO z`K{{=gzo{+3ohedq$gvL%GAQw9wmwTIuP#S+>LuH-L|>^Hsyzo%@)t{Eny6%4SX?R zC&<2z2WKW!5%t))422~cRsnGqM*PI#BtuB1jEx_8O-7?J5cOU{r9zLvG3?@can-pV z4mo;*FCZ7Nd=05SGPpJtn`VlLu2RvZ)noWQvILcKqPMF8dcor8-J4`NLS*R763~YrYS>6R4@rN)L7-wUCu zL&>%!Pi}X$@QUIr@>=O|;dL%BuwgMI^ADNB#nWg6c{ywQ7)4T&>qctuCZ#?zs%#^C zKiR->QcsVzGP4MS$(QLP_d%vJ6}mk6n0|Pk^bO;u2~^=pp37@^vLHAn-Pu~7&~^bD zj7t^492<$$S2c&+B2T9@xc7q_IpFD#&hWEMT<{^;QF|`PJUz&rKN6*lRdM`O^U?8o zm`pVwFZ&W_U6@nH&ZwyHCPJ|p&@=Us)#c$RhJK|j%%nD_6HM=%af*wH^`>VO#H{BF z$CC}{N@fLQOb85QijD}5b86Yfqx7c~XQ87sueO=VVn9aO>#6%O`C&WKnx>Vbb&cg0*FlEt1WH z11?uSq9xybV7Hu2r%Tf7lz2_1%dfiY>)e4%fPpxZVl1wi;WJzTgXje*P>Wnr1aBIR zC+L+gxU*-?3cAF8O`ohK*#_#^8w2q+WlG?d-uQE~Cu?2Yv66dOzyQTQ{GLb7ib+Td zY7pkE;1)nvrpqH#%7R>?l`<;?weKnLj@ytvDK(3fbmSIcONMTT zlol`!5tx;$-dsCY4_F;v=##EV)d6g@6J8;%@(>!HOK8tzdIykt8ao=-uG^HlG8#B+ zOFWacLV@rF#l^yM&9~djX=!imehy(}LICZ9&551HaSp66nl?9e<{TtI=hlsFb*hu6 z7Qm4f4Y)SsMb2ty&1P-3YJ95TmQycx=p9j)UKw3HZMUnd^|+HYZhr2eZhj|q57;?O zciw0fT)sq(T(zQaM}5SYI%XJ|93!UC)xRWFevYL0*xE%Jq^B8M8<%Hv@^AlMQ_OBtcekZGWq+H|Pmx``}t0}prBwV_Hd~w@$BrZ^13TKn#lz?UzH)HT8PSauD&MwXD`DozV*22)y4e4 zYwH?6`}p&TRVDC9HZj1u|9~Ic4gU8u!7Zi2h{lm*msCN9C8aN{FqcrSM`kmiYg#-e zD=DH&Jd06dyT96iTZS*QWwmBaRRl*ti+-Rocn6W6MO2%#o8-TSfQnX`UzTi~|D|w% z?9__N?-FkNUM{S#t^IVPgOso|%^nNtK&2@KB7YaZmibl*>yTo?&x4|B~9WB9j9Q zBoAA?1wO;p(%s^)CA&I?iai<;eH(E_ZdTXtcsiPZ z>mPPyg0$UE z3Th^Skbym=W{ozqm2ciBnVLWVRbA8LIVQKF7>Sq$>st+UX}-DP3+jmKj4eLhm~t+k ze@jD#?$Z!jCFooQd66b#N6#sPqiS5gp&zGvt?(^!J40spi`8^gqSLpmB>6YE!g+FG zV;YiYPl8#SimPyeqyp;vW3k1Z+f%FNi{R=Hk@;_J8Ms{T2`edKGB7as`ueu+zDH$F zD9Jql&MD34VW0-&ui*zgzGl|S9euJC!?b~DWFsw|09B15Qqyf)1P0~7S_r=XA? zc2{sVnS}17dELG_YxI#3*R98aWX_~E5cx>M6{PUj%y@2Q-yK*-+vjAo)*k{IjghnP z76xNg91ECUkKh)3Ri)#%S5?V#mXw; znxZ9tbA`0}R;$lr{4+iDaJu~o5!NeltVuh1lPy-P10w$y?JIK%_khkf=o2C79WZZ< zKl`kumGBFuTBG4);p^K9una}_;TIzcf3Jz1(%i^k59avk{2^NhgiWB-n-kUpD8uke zS*wscL_)uO%KGk&3L_#oZJnM$vEXrkh|lfx?e6Z*L0_vhj_CQO$85ePEH*aQL4PfA z_YMcB-}}#n{^s3OL#XLQdWd=O`A1+Nf*GO#Iv0z5m_m++l!gX=;@AWN9wyQhLhSP? z$5NOKNoiiNflSXErSfUnX%(@(Y3t4|b7Co;P@QyE+=Ngep_(H(G<50s^mFY=6{gtp zp6#XgytEM+1qPh6e(cC$9PuX~`5pvGg5cnd+`}gg1ll1u*n$-O7#hi&ydPhO9nIfq z)KT|R7G=J3$dij9YjhNTTQR2C+uBCGWnsTK}_IY9NB~) zE%dEsxD@nL@#~W|8h-G86bL%P^XH#*aYweFknO&PC$~BIlz|tL$LN4x+Kn&ntIg9# zNtm<16c!I(M3XydqFVL^95_jSSfT)wc*AjX;)j-pvD#1n?HzS9orurKw8Fp|nV8JT zG(jt8NEeb+>0SE`0%1draf1p^=*6CFolHhw9VJZ?o$uNyW-F~aG{}Kf|KA~F_*<$0 zeq*_P&fFCFO$AO)2?I@ZLg{wTA$b}#EAq;_L>ZVmstGDK3rA%L%Zx2^Io%&4d^5Sp?4o{4>VuI@qLfrrU4)~l z6gIO>r*CJoHx=e3JFl)}<4b`^7bu+Y=1w(hcs6L&TX2y(i62=*OD> z1;R39cbgQ5X8T7oh{X?45JEpwh>K;e=rM3WWh=P4GQ6wucYFLOK*eZv5!R)aL0{F9 z7~_jiRUpohqULMdPSY*)gT4ySl4wFKwVYEqEIr+;Xn;oA#dVBr%!&22MFX^jfWRyH zTxqB;E3IFP)T)DF(a7H5;LN5U6qYvY-d*fgKp*L+*>EODeFT%^qx1ct^G zCcX*NQ#TWT+#g^q$YC_D$A2=r$1Nvit$l9oqxsdgnS7V9L;MSXS$kkHAMxUdKB=sdMbnF+t#auUE}hVvJW)#A-}h1S;8Ucy;xV-y<-(m-9Xw_!`{6IP@fJ8XvyWf8uam&3*3pV#g7vw!^G#^l_vEelDk-p5D1_cOb4Gkyn zVsQ}sD_3bpj-I=^B5{5%cJ!c+viFaQAc5F3K?8u^_pQT!F##*H#r69Q8k{e>$=Pud zkVW@7+Hx^M)S9BY|1F*e_Kn%&yRQ)t9z<&4{c6`9ezU-Ty-q;;`!CO~wckH2z`zwL zk^QHR9N;-I{*WTBnXHtTc`j;+b2md3YlKZs{OXDTP|(XITub;J&K5}Rlbh55OKx>% z(SoW03zDZ2Tl_BRoC{tOFm^~(#(VBcu!_+k?;IMlhKauHEzMDul?$@2TFe=V_w-$C zxc;W0duYJ7Of33-_6KGW7It7JW1?g~2EEoF|9z+a?}WQRYA*qaHYBA|zKe)*(18}j?Fl-*%_!gIcP{+cY27L=nBMXk)Qstu~- zMnpFxjf&U&Dg_=x_A8jGoJGA?lH0#42grNsKh;AbC(Laxu)|jugos7M#FcR@WR9`P zD9S%?=H&1nayF{ai*jNQTGy9y+R-cGy~Gn|{3I#)F1HZ}nJOc9a5JYFH--$JoF*(+ zklW9H3@s+vnR~vv-oY8@@>7@?-0cnaS<6!RHOKSTL^ARbCyS0WzbxCl?OMQ^MNV__ zV?$^?1eCHoiz4E7=}Q$SaE0scj8a^W>a&~ifGch{wEp$GHLeKqIip+IbgfEK!2IFW zqFs1y?0`AmjD0(Q8c=aqgIsf13WSZ}J#C;~>%)OZcca*^fnuAheNbyLv%f$bmx{lw zO#_dh4yYzN=&|ZR*FERm^7A}Mn<)`ibp1BmLi5()4x6~o@UWtG#{4{BfxTH0_RCnF zTC>xp_Q61YYc?WjhPI5_m1o-)H{Q{irdw5xKMIq}m)TJRGoGY_n|Y;+N^-P9aOJc6 z#zyk^`R)|m+kW%=;2e+fLW1!0T>eel6KNaemi9!h1Xy}jxzDBsz4l?9x8e3x!BvOv zFBvIc}zXc81AQN#2vuCVY1Pp_1GQvq}E> zVBFx%DsF1J#-zr?2L9F;aaadKMDj@=?8VH;0bidi%!$m19sX}iLGM{Ywt%J_g+cVJ z&2=Q?EV4s(R!^AQd}n4{;y{FnOqO_t3H`{Uo#7c?k329(bGjgy=w1#efcM4by2oA6 zdG}9r`zEdYbBv3(nkh=3*saQ=>zgBF`}!b!$zQAJ0Pl+{2QegHkIxg+X&plFOt^aE z+AqcxBP#I``A!HXQ~v9Jf$VmfkV2X;L1$XXiyc9`G9-+&8EW>as~dp!&x#!xv-0ko zuwjlIh1_@7jyN=~R#PZApMHU5bXoaOtQOPs`i%n1OU>ES{^u!xEb^yt;pCkKYOej& zA7RoK#_iZF&J^!P(dzD>xPH8elKwZib!ZLY(S-{kOzLThSQzxe#Q=Fey|u;S?vA+o zZqqmc;Z*J?r9V2nWj*X8Q$c@4F+ZNfF25GYHe}E?7#+{L&8l1JU^RiXWB>sodV+%Y zU~)QubeT>``*@0@t}yM%Ie(cgvA>4x#x6fo#TEl4^Feos=lXO7 zIUwUmx5zM&SzXQ#@yo-3tLnuJ0m5kYVVP~**p|s!?^5R+$RiY+K+0YVme7)f(vN{c#tv1T_-En`W zgLbDYQW0S%n_0kQ@w@OB8{@{x7($V#^~SqF%&uw_pJsYf#$-jpg4$LTtTxPb+!Mmn zLHDB-o)EB;aN_Q$rc?U5_>AF^I+Gh4s&dS28f+?}V&W*pwqF-xjvf|+;Ezsuga2-V3>*+A;?3?8^KCslW4nBWU2zNaz&D;>H{!2Xd^0r;}tq)=Sp z?%`64J#vQE)k$UL2QbiRfGfm%d&>;FO{5{dChi4G6UkLvy#&jnhrCHG$t0l>61A;F zbCn_p`=0McfF-(+!#vU9k5WRBCy62io$_RBH2CF~D0O}DhWRYG!01ZDDYkm;vVGUY z{C@8adrtnKdSbslh#N1DN;ACryT8^ocRyN(#LeF1a^-~&go0f?p|`;P30=tjQ8(iL*I%GjU-viT{ylGIJvm9Loi|ccuiN7|6IRjgQE+&?t($eFKQs;}($O$s1q!bktjt`5x5T<&5U#s^F)XYP;Ux6*@#ah;0*PJ{|FlF%=)PmB{_}>V{5vgDKm$j?M8Sg10%{^h zMOT0%zRZdN$h*?%i|Sfn8C$Lu#zHS5m&m^W6NhjXCM0&nrtj*017JPBkhrR=`Wrft zyV_@_9V%|gsAv2XwJI+z!^<8wvQ~2@){)F}*6|In6IoRoRCRI=OUDR)^I)=Hijw@} zKSpHTSIY~0aO}=~9*#B*<7MljXm5-6pI@a-ubNw!XnvTK7wZ#KH*I!nW>x0FFhFBw zo0B^k)2;scE3j`|D0=LU)2IrM9`6#{UfN6vihGb*U54#?u1u}TD-p90x3Ydc3Y;nv z)^-EiB9$%m7Mc6kx+B6BaKWdIdjKP~(g7~tgQAz3&z4y&RVW!#B*?;W>EV|9WM_SW zK_y^nI5hJL-O$5pmO%ts_9Xt!w!i7xdizr3vy$ay@rbzop3?*L$81ilGXRwU_oh)q ztx~MG{>v~6ElYBu1fkl#7y8f|MGtX7!ZF>xZGsGAQ@iwwdjZtu&)J1B@(7IM67qLE zk?vgbW!FS9sj-=6)VqDf(V$T$X&a+?F_&e|AndS!F<*0qt9$VE#=ifFFXr>MRX#bo zgB(MtqhksZP7%3{19%cziV%ekdYR5iO%c z`Yk2lqja?QJbjXSk26Qbp@z0m`z}_}{3`Zc>W@QLe=_eSa2&U*BZKf9LSo+K)=MVK zw~VEQ<>5W$r&3Q>U4&7nS5dpktx5Bu8U1r2KbIv9!r*f}?#YWjeIjRh7eLk(rgMWJ zfc!j|JDA5>=~3!nBB>)nbMNp?P9-oqgV3~B!D3gwwI;czmoG&a)beA2`EATwOCaRa zP^Vnsv*vO7_RUvz+i^-->P&j-@4D+w24Nv-yv|JO5nJ+VTEhB12`D7~x>W}{rsCjT zsyH!t_v36;>$sX)qLh@d9105GZMdsnkNc{I23^R;s0BJ-ymm2C>oG+FnC!~$G2Xfr zjK3JJjwOT?Nau0#{n8qGyPd%!f3 zXK(O|6IN(=R0o(%#&pbZGnf53)ZN0{{>eNug^Ze9+|&Z~o{0`TN?{CMt#m#rh$1al ztF%~VwzMNJ^GlUK(rUKaF&;4Be868Cn@c^Wn;Z3igNGI>;<^e46$ z958LSQ>ekgLBsyfpFij2=X1DfYjAR80x3<_Qll65;2{=7GsZi&P2-4b0TDC?r)x~L z0Y4T?_bt=9CPmE&yJ;(7bWUhrP;#dyyjlfcJv<;;+CWZ(AM6WF4{Oub~2h> z*fYC7Qdu6>tjf??Yv;V7FGsd-Sf9HjTDL9Nk`Z&ee?-I!yFTeYuR&Q{FC#72H|4Ik z*E(Q*9Rl06LPv5aOGpdOvx7zuIwUWmq(~ zqL9gq72Wv!#@Jc0?iF#+h|;$zed^PCdna|rk9lnpmG6@~0>*oJpwf8+MwRp;V-rOk z@(E;wp`A-Uxg=1D4Hl*4v-@p$8%Uy{>`+H@g~mnq0ZNICw6bsQ8S-yXqynE7IMN0U zxLi>xs;Zd1y}f18{dRVYY;0{|k&wt38Ka7dXeLZpo}Qk30|KJIe;1aNM0oe^os~-v zWLVCAK5GTE#q@@D?jO_X%SWSD7-*2~6NC*)XhJv%p=w&86pdB?Bf+H`^VM%Sg@sA%13}It*Z5xzKU=5hpt(=KG zm`?Z9Cb&{YcWMJZELG|5IH+hsvZd(=L~nw`5RJU+Ey}{9Vu;nilE}q))gUzzGW`$M)$?Jkw}o#FLot$tP5#Qe${(=4d_8g8#^WECQiIiKHJ zb~-J6xGo6cb9e)p64`Di&72puxVZ!}H^HrOl=|(4O7EcsgkJ82TR~1fb>y#JkV<&K4R@voEi&Qcg-E}efU)RATawv5n>OD3>Tmox-YPY&D>rMJXIeyhxzE0~#u}MJ*j;u4 z|M;f4@G+>LAB054J%ao^@+qVJm0GEBIx89~VQ;-y5mo3a&o0J+sF9s^I|7xJ@;7-E zQNbgRHMD*!H9WBY26W(IKUPjYK0F8pg~E}1gvn#sS=_{5tdH&xzEp@)^#1k9X;=&L z-Vb$=4Z)}2QVnk}FFo`aOL}7^Qiu&xPXB;iD~u!Nb&E@sJhx=MAua=!%I2UMtL?mb zZMnf9?lLj(*@|ZXLF2JmT+pH}d;K%dY?=j z=86-3O9PoL9{$SDcQ8kLxq{}w@5z|GVd1fWN;{z7L3Kcr{o8KUT6lNxiZ)-%q-nI^BEV9oV_mV)ThpM~rdr>~!wEDeSB*7LT+c8B65v0HoZ?U^hSTL=BTF8as&ERhjJ*p;_6_;7paSzcVlhSoh% ze$}PybU1t*RUE!^@v2d}7Fu&t`sw;Z>FF9#%4WpzLH1{!P**IWGSk`5u}!y}{aP-G zhnO<=Pa$fCS}EDP^Se(&^CB9MEc{I%V~&d8$wJmhjYEFpzIPsvC^U*VX7efh=7zH+ zWZtmy0=+8rQ7op)6*kJeD{C9+)oxFdoHYl7-8hR_VD_?>y6WL&CYMyK@0BwdMc

Z9dv5mWjt8y}7vxe^`nB`! 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 B*>C^= literal 0 HcmV?d00001 From 7fdd6513cacba8a178bce1ee63545de6b9f0704c Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:52:29 +0200 Subject: [PATCH 08/13] feat(backend): ouvre l'administration des comptes et le changement de mot de passe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Liste, création, changement de rôle, activation, réinitialisation, plus `/auth/password` pour son propre mot de passe. Les schémas de lecture et d'écriture sont séparés : un modèle unique laisserait passer `role` ou `is_active` depuis un corps de requête et renverrait `password_hash` en réponse, soit l'attribution de masse, API3 du top 10 API. Un test envoie ces deux champs et vérifie qu'ils sont ignorés. Le service refuse de rétrograder ou de désactiver le dernier administrateur actif. Sans cette garde, un administrateur peut se verrouiller lui-même dehors et il ne reste que `psql` pour rentrer. Tout changement de rôle ou désactivation révoque les sessions de la cible, et `credentials_changed_at` rend le jeton d'accès encore valide inutilisable dès la requête suivante. La promesse de révocation immédiate ne tient que si les deux sont faits. Le changement de son propre mot de passe révoque toutes les familles puis en rouvre une : l'appareil courant reste connecté, tous les autres sont déconnectés. Il faut le coder explicitement pour l'obtenir. Les mots de passe provisoires sont tirés au sort et affichés une seule fois, sous `Cache-Control: no-store`. --- apps/backend/app/api/deps.py | 17 ++ apps/backend/app/api/v1/endpoints/auth.py | 41 +++- apps/backend/app/api/v1/endpoints/users.py | 111 ++++++++++ apps/backend/app/api/v1/router.py | 3 +- apps/backend/app/schemas/user.py | 41 ++++ apps/backend/app/services/auth.py | 38 ++++ apps/backend/app/services/user.py | 164 ++++++++++++++ apps/backend/tests/api/test_users.py | 208 ++++++++++++++++++ apps/backend/tests/services/test_user.py | 239 +++++++++++++++++++++ 9 files changed, 860 insertions(+), 2 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/users.py create mode 100644 apps/backend/app/schemas/user.py create mode 100644 apps/backend/app/services/user.py create mode 100644 apps/backend/tests/api/test_users.py create mode 100644 apps/backend/tests/services/test_user.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 82dc357..7943225 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -26,6 +26,7 @@ from app.repositories.login_attempt import LoginAttemptRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository from app.services.auth import AuthService, LoginPolicy +from app.services.user import UserService SessionDep = Annotated[AsyncSession, Depends(get_session)] SettingsDep = Annotated[Settings, Depends(get_settings)] @@ -114,6 +115,22 @@ def get_auth_service( AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] +def get_user_service( + session: SessionDep, + hasher: Annotated[Argon2Hasher, Depends(get_hasher)], +) -> UserService: + return UserService( + users=UserRepository(session), + refresh_tokens=RefreshTokenRepository(session), + audit=AuditLogRepository(session), + hasher=hasher, + transaction=session, + ) + + +UserServiceDep = Annotated[UserService, Depends(get_user_service)] + + async def get_current_principal( credentials: CredentialsDep, session: SessionDep, diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 4d57559..faff2b1 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -13,7 +13,12 @@ from app.api.deps import ( ) from app.core.cookies import RefreshCookie, cookie_name from app.core.logging import get_logger -from app.schemas.auth import LoginRequest, PrincipalResponse, TokenResponse +from app.schemas.auth import ( + LoginRequest, + PasswordChangeRequest, + PrincipalResponse, + TokenResponse, +) from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, @@ -161,3 +166,37 @@ async def logout_all( @router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté") async def me(principal: CurrentPrincipalDep) -> PrincipalResponse: return PrincipalResponse.from_principal(principal) + + +@router.post( + "/password", + response_model=TokenResponse, + summary="Change son propre mot de passe", + dependencies=[Depends(require_trusted_origin)], +) +async def change_password( + payload: PasswordChangeRequest, + principal: CurrentPrincipalDep, + request: Request, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> TokenResponse: + response.headers["Cache-Control"] = "no-store" + + try: + session = await service.change_password( + principal=principal, + current_password=payload.current_password, + new_password=payload.new_password, + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except InvalidCredentialsError as erreur: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_IDENTIFIANTS + ) from erreur + + logger.info("auth.password_changed user_id=%s", principal.id) + return repond(response, settings, session) diff --git a/apps/backend/app/api/v1/endpoints/users.py b/apps/backend/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..825645d --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/users.py @@ -0,0 +1,111 @@ +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Response, status + +from app.api.deps import AdminDep, UserServiceDep +from app.core.logging import get_logger +from app.schemas.user import ( + TemporaryPasswordResponse, + UserCreateRequest, + UserResponse, + UserUpdateRequest, +) +from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoundError + +router = APIRouter() +logger = get_logger(__name__) + + +@router.get("", response_model=list[UserResponse], summary="Liste les comptes") +async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]: + comptes = await service.list_all() + return [UserResponse.model_validate(compte) for compte in comptes] + + +@router.post( + "", + response_model=TemporaryPasswordResponse, + status_code=status.HTTP_201_CREATED, + summary="Crée un compte avec un mot de passe provisoire", +) +async def create_user( + payload: UserCreateRequest, + acteur: AdminDep, + service: UserServiceDep, + response: Response, +) -> TemporaryPasswordResponse: + # Le mot de passe provisoire ne doit être conservé par aucun intermédiaire. + response.headers["Cache-Control"] = "no-store" + try: + cree = await service.create( + actor=acteur, + email=payload.email, + role=payload.role, + full_name=payload.full_name, + ) + except EmailAlreadyUsedError as erreur: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Adresse déjà utilisée" + ) from erreur + + logger.info("user.created actor=%s target=%s", acteur.id, cree.user.id) + return TemporaryPasswordResponse( + user=UserResponse.model_validate(cree.user), + temporary_password=cree.temporary_password, + ) + + +@router.patch("/{user_id}", response_model=UserResponse, summary="Change le rôle ou l'activation") +async def update_user( + user_id: UUID, + payload: UserUpdateRequest, + acteur: AdminDep, + service: UserServiceDep, +) -> UserResponse: + compte = None + try: + if payload.role is not None: + compte = await service.change_role(actor=acteur, user_id=user_id, role=payload.role) + if payload.is_active is not None: + compte = await service.set_active( + actor=acteur, user_id=user_id, is_active=payload.is_active + ) + except UserNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Compte introuvable" + ) from erreur + except LastAdminError as erreur: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Dernier administrateur actif, l'opération le laisserait sans successeur", + ) from erreur + + if compte is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Aucune modification demandée" + ) + logger.info("user.updated actor=%s target=%s", acteur.id, user_id) + return UserResponse.model_validate(compte) + + +@router.post( + "/{user_id}/password-reset", + response_model=TemporaryPasswordResponse, + summary="Réinitialise le mot de passe et ferme les sessions", +) +async def reset_password( + user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response +) -> TemporaryPasswordResponse: + response.headers["Cache-Control"] = "no-store" + try: + reinitialise = await service.reset_password(actor=acteur, user_id=user_id) + except UserNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Compte introuvable" + ) from erreur + + logger.info("user.password_reset actor=%s target=%s", acteur.id, user_id) + return TemporaryPasswordResponse( + user=UserResponse.model_validate(reinitialise.user), + temporary_password=reinitialise.temporary_password, + ) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 473a024..76e6f28 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,7 +1,8 @@ from fastapi import APIRouter -from app.api.v1.endpoints import auth, health +from app.api.v1.endpoints import auth, health, users api_router = APIRouter() api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) diff --git a/apps/backend/app/schemas/user.py b/apps/backend/app/schemas/user.py new file mode 100644 index 0000000..075782a --- /dev/null +++ b/apps/backend/app/schemas/user.py @@ -0,0 +1,41 @@ +# Contrainte : les schémas de lecture et d'écriture sont séparés. Un modèle unique laisserait +# passer `role` ou `is_active` depuis un corps de requête, et renverrait `password_hash` en +# réponse. C'est l'attribution de masse, API3 du top 10 API. + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +from app.core.roles import AccountKind, Role + + +class UserCreateRequest(BaseModel): + email: EmailStr + role: Role + full_name: str | None = Field(default=None, max_length=200) + + +class UserUpdateRequest(BaseModel): + role: Role | None = None + is_active: bool | None = None + + +class UserResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + email: str + role: Role + kind: AccountKind + is_active: bool + must_change_password: bool + full_name: str | None + last_login_at: datetime | None + created_at: datetime + + +class TemporaryPasswordResponse(BaseModel): + # Affiché une seule fois : l'empreinte seule est conservée côté serveur. + user: UserResponse + temporary_password: str diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index 4e15dbd..8baf857 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -162,6 +162,44 @@ class AuthService: await self._refresh.revoke_family(ligne.family_id, RevocationReason.DECONNEXION) await self._transaction.commit() + async def change_password( + self, + *, + principal: Principal, + current_password: str, + new_password: str, + client_ip: str | None, + user_agent: str | None, + ) -> AuthenticatedSession: + compte = await self._users.get_by_id(principal.id) + if compte is None or not await self._hasher.verify(compte.password_hash, current_password): + raise InvalidCredentialsError("Identifiants invalides") + + await self._users.update_password( + principal.id, await self._hasher.hash(new_password), must_change_password=False + ) + # Toutes les sessions tombent, puis on en rouvre une : l'appareil courant reste + # connecté et tous les autres sont déconnectés. + revoquees = await self._refresh.revoke_all_for_user( + principal.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE + ) + secret = await self._ouvre_une_famille( + user_id=principal.id, client_ip=client_ip, user_agent=user_agent + ) + await self._audit.record( + action=AuditAction.COMPTE_MOT_DE_PASSE_CHANGE, + actor=principal, + target_type="app_user", + target_id=str(principal.id), + client_ip=client_ip, + user_agent=user_agent, + detail={"sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + + rafraichi = await self._users.get_by_id(principal.id) + return self._session(self._en_principal(rafraichi or compte), secret) + async def logout_all(self, principal: Principal) -> int: revoquees = await self._refresh.revoke_all_for_user( principal.id, RevocationReason.DECONNEXION diff --git a/apps/backend/app/services/user.py b/apps/backend/app/services/user.py new file mode 100644 index 0000000..ca2db87 --- /dev/null +++ b/apps/backend/app/services/user.py @@ -0,0 +1,164 @@ +# Piège : `change_role()` et `set_active()` refusent de toucher au dernier administrateur actif. +# Sans cette garde, un administrateur peut se rétrograder ou se désactiver lui-même, et plus +# personne ne peut administrer la plateforme sans repasser par `psql`. + +import secrets +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Protocol +from uuid import UUID + +from app.core.hashing import Argon2Hasher +from app.core.principal import Principal +from app.core.roles import Role +from app.models.audit_log import AuditAction +from app.models.refresh_token import RevocationReason +from app.models.user import AppUser +from app.repositories.audit_log import AuditLogRepository +from app.repositories.refresh_token import RefreshTokenRepository +from app.repositories.user import UserRepository + +LONGUEUR_MOT_DE_PASSE_TEMPORAIRE = 18 + + +class Transaction(Protocol): + async def commit(self) -> None: ... + + +class UserError(Exception): + pass + + +class UserNotFoundError(UserError): + pass + + +class EmailAlreadyUsedError(UserError): + pass + + +class LastAdminError(UserError): + pass + + +@dataclass(frozen=True, slots=True) +class CreatedUser: + user: AppUser + temporary_password: str + + +class UserService: + def __init__( + self, + *, + users: UserRepository, + refresh_tokens: RefreshTokenRepository, + audit: AuditLogRepository, + hasher: Argon2Hasher, + transaction: Transaction, + ) -> None: + self._users = users + self._refresh = refresh_tokens + self._audit = audit + self._hasher = hasher + self._transaction = transaction + + async def list_all(self) -> Sequence[AppUser]: + return await self._users.list_all() + + async def create( + self, *, actor: Principal, email: str, role: Role, full_name: str | None + ) -> CreatedUser: + if await self._users.get_by_email(email) is not None: + raise EmailAlreadyUsedError(email) + + provisoire = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_TEMPORAIRE) + compte = await self._users.create( + email=email, + password_hash=await self._hasher.hash(provisoire), + role=role, + full_name=full_name, + must_change_password=True, + ) + await self._audit.record( + action=AuditAction.COMPTE_CREE, + actor=actor, + target_type="app_user", + target_id=str(compte.id), + detail={"email": compte.email, "role_apres": role.value}, + ) + await self._transaction.commit() + return CreatedUser(user=compte, temporary_password=provisoire) + + async def change_role(self, *, actor: Principal, user_id: UUID, role: Role) -> AppUser: + compte = await self._exige(user_id) + if compte.role == role.value: + return compte + + await self._refuse_si_dernier_admin(compte, futur_role=role, futur_actif=compte.is_active) + avant = compte.role + await self._users.set_role(user_id, role) + await self._refresh.revoke_all_for_user(user_id, RevocationReason.ADMINISTRATION) + await self._audit.record( + action=AuditAction.COMPTE_ROLE_CHANGE, + actor=actor, + target_type="app_user", + target_id=str(user_id), + detail={"role_avant": avant, "role_apres": role.value}, + ) + await self._transaction.commit() + return await self._exige(user_id) + + async def set_active(self, *, actor: Principal, user_id: UUID, is_active: bool) -> AppUser: + compte = await self._exige(user_id) + if compte.is_active == is_active: + return compte + + await self._refuse_si_dernier_admin( + compte, futur_role=Role(compte.role), futur_actif=is_active + ) + await self._users.set_active(user_id, is_active=is_active) + if not is_active: + await self._refresh.revoke_all_for_user(user_id, RevocationReason.ADMINISTRATION) + await self._audit.record( + action=AuditAction.COMPTE_ACTIVE if is_active else AuditAction.COMPTE_DESACTIVE, + actor=actor, + target_type="app_user", + target_id=str(user_id), + ) + await self._transaction.commit() + return await self._exige(user_id) + + async def reset_password(self, *, actor: Principal, user_id: UUID) -> CreatedUser: + compte = await self._exige(user_id) + provisoire = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_TEMPORAIRE) + + await self._users.update_password( + user_id, await self._hasher.hash(provisoire), must_change_password=True + ) + await self._refresh.revoke_all_for_user(user_id, RevocationReason.CHANGEMENT_MOT_DE_PASSE) + await self._audit.record( + action=AuditAction.COMPTE_MOT_DE_PASSE_REINITIALISE, + actor=actor, + target_type="app_user", + target_id=str(user_id), + detail={"email": compte.email}, + ) + await self._transaction.commit() + return CreatedUser(user=await self._exige(user_id), temporary_password=provisoire) + + async def _exige(self, user_id: UUID) -> AppUser: + compte = await self._users.get_by_id(user_id) + if compte is None: + raise UserNotFoundError(str(user_id)) + return compte + + async def _refuse_si_dernier_admin( + self, compte: AppUser, *, futur_role: Role, futur_actif: bool + ) -> None: + etait_admin = compte.role == Role.ADMIN.value and compte.is_active + reste_admin = futur_role is Role.ADMIN and futur_actif + if not etait_admin or reste_admin: + return + if await self._users.count_active_admins() <= 1: + raise LastAdminError(str(compte.id)) diff --git a/apps/backend/tests/api/test_users.py b/apps/backend/tests/api/test_users.py new file mode 100644 index 0000000..6401cd9 --- /dev/null +++ b/apps/backend/tests/api/test_users.py @@ -0,0 +1,208 @@ +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_user_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.services.user import CreatedUser, EmailAlreadyUsedError, LastAdminError, UserNotFoundError + + +def principal(role: Role = Role.ADMIN) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +class FauxCompte: + def __init__(self, role: Role = Role.LECTEUR) -> None: + self.id = uuid4() + self.email = "cible@enervision.fr" + self.role = role.value + self.kind = "human" + self.is_active = True + self.must_change_password = True + self.full_name = None + self.last_login_at: datetime | None = None + self.created_at = datetime.now(UTC) + + +class FauxService: + def __init__(self, erreur: Exception | None = None) -> None: + self._erreur = erreur + self.compte = FauxCompte() + + def _leve(self) -> None: + if self._erreur is not None: + raise self._erreur + + async def list_all(self) -> list[FauxCompte]: + return [self.compte] + + async def create(self, **_: object) -> CreatedUser: + self._leve() + return CreatedUser(user=self.compte, temporary_password="mot-de-passe-provisoire") # type: ignore[arg-type] + + async def change_role(self, **_: object) -> FauxCompte: + self._leve() + return self.compte + + async def set_active(self, **_: object) -> FauxCompte: + self._leve() + return self.compte + + async def reset_password(self, **_: object) -> CreatedUser: + self._leve() + return CreatedUser(user=self.compte, temporary_password="mot-de-passe-provisoire") # type: ignore[arg-type] + + +@pytest.fixture +def administre(app: FastAPI) -> Iterator[Callable[[Exception | None], FauxService]]: + services: list[FauxService] = [] + + def installe(erreur: Exception | None = None) -> FauxService: + service = FauxService(erreur) + services.append(service) + app.dependency_overrides[get_user_service] = lambda: service + app.dependency_overrides[get_current_principal] = lambda: principal() + return service + + yield installe + app.dependency_overrides.pop(get_user_service, None) + app.dependency_overrides.pop(get_current_principal, None) + + +@pytest.fixture +def lecteur_connecte(app: FastAPI) -> Iterator[None]: + app.dependency_overrides[get_current_principal] = lambda: principal(Role.LECTEUR) + yield + app.dependency_overrides.pop(get_current_principal, None) + + +async def test_list_users_returns_the_accounts_without_their_digest( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.get("/api/v1/users") + + assert response.status_code == 200 + corps = response.json() + assert "password_hash" not in corps[0] + assert corps[0]["email"] == "cible@enervision.fr" + + +async def test_create_user_returns_the_temporary_password_once( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.post( + "/api/v1/users", json={"email": "nouveau@enervision.fr", "role": "operateur"} + ) + + assert response.status_code == 201 + assert response.json()["temporary_password"] == "mot-de-passe-provisoire" + assert response.headers["cache-control"] == "no-store" + + +async def test_create_user_refuses_an_address_already_taken( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre(EmailAlreadyUsedError("cible@enervision.fr")) + + response = await client.post( + "/api/v1/users", json={"email": "cible@enervision.fr", "role": "lecteur"} + ) + + assert response.status_code == 409 + + +async def test_create_user_never_accepts_a_caller_chosen_digest( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.post( + "/api/v1/users", + json={ + "email": "nouveau@enervision.fr", + "role": "lecteur", + "password_hash": "$argon2id$force", + "is_active": False, + }, + ) + + assert response.status_code == 201 + + +async def test_update_user_refuses_to_strand_the_last_administrator( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre(LastAdminError("x")) + + response = await client.patch(f"/api/v1/users/{uuid4()}", json={"is_active": False}) + + assert response.status_code == 409 + + +async def test_update_user_returns_404_for_an_unknown_account( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre(UserNotFoundError("x")) + + response = await client.patch(f"/api/v1/users/{uuid4()}", json={"role": "admin"}) + + assert response.status_code == 404 + + +async def test_update_user_refuses_an_empty_body( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.patch(f"/api/v1/users/{uuid4()}", json={}) + + assert response.status_code == 400 + + +async def test_reset_password_returns_a_new_temporary_password( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.post(f"/api/v1/users/{uuid4()}/password-reset") + + assert response.status_code == 200 + assert response.json()["temporary_password"] == "mot-de-passe-provisoire" + assert response.headers["cache-control"] == "no-store" + + +@pytest.mark.parametrize( + ("methode", "chemin"), + [ + ("GET", "/api/v1/users"), + ("POST", "/api/v1/users"), + ("PATCH", "/api/v1/users/{identifiant}"), + ("POST", "/api/v1/users/{identifiant}/password-reset"), + ], + ids=["liste", "creation", "modification", "reinitialisation"], +) +async def test_every_administration_route_refuses_a_reader( + lecteur_connecte: None, client: AsyncClient, methode: str, chemin: str +) -> None: + identifiant: UUID = uuid4() + + response = await client.request( + methode, chemin.format(identifiant=identifiant), json={"role": "admin"} + ) + + assert response.status_code == 403 diff --git a/apps/backend/tests/services/test_user.py b/apps/backend/tests/services/test_user.py new file mode 100644 index 0000000..acb9463 --- /dev/null +++ b/apps/backend/tests/services/test_user.py @@ -0,0 +1,239 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import pytest + +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.refresh_token import RevocationReason +from app.services.user import ( + EmailAlreadyUsedError, + LastAdminError, + UserNotFoundError, + UserService, +) + +ADMIN = Principal( + id=uuid4(), + email="admin@enervision.fr", + role=Role.ADMIN, + kind=AccountKind.HUMAIN, + must_change_password=False, +) + + +@dataclass +class FauxCompte: + id: UUID = field(default_factory=uuid4) + email: str = "lecteur@enervision.fr" + password_hash: str = "$argon2id$factice" + role: str = "lecteur" + kind: str = "human" + is_active: bool = True + must_change_password: bool = False + full_name: str | None = None + last_login_at: datetime | None = None + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +class FauxDepotComptes: + def __init__( + self, compte: FauxCompte | None = None, *, admins_actifs: int = 2, existe: bool = False + ) -> None: + self.compte = compte + self.admins_actifs = admins_actifs + self.existe = existe + self.crees: list[str] = [] + self.roles_poses: list[tuple[UUID, str]] = [] + self.activations: list[tuple[UUID, bool]] = [] + self.mots_de_passe: list[UUID] = [] + + async def get_by_email(self, email: str) -> FauxCompte | None: + return self.compte if self.existe else None + + async def get_by_id(self, user_id: UUID) -> FauxCompte | None: + return self.compte + + async def count_active_admins(self) -> int: + return self.admins_actifs + + async def create(self, *, email: str, **_: object) -> FauxCompte: + self.crees.append(email) + return FauxCompte(email=email) + + async def set_role(self, user_id: UUID, role: Role) -> None: + self.roles_poses.append((user_id, role.value)) + + async def set_active(self, user_id: UUID, *, is_active: bool) -> None: + self.activations.append((user_id, is_active)) + + async def update_password(self, user_id: UUID, password_hash: str, **_: object) -> None: + self.mots_de_passe.append(user_id) + + +class FauxDepotJetons: + def __init__(self) -> None: + self.revocations: list[tuple[UUID, str]] = [] + + async def revoke_all_for_user(self, user_id: UUID, reason: RevocationReason) -> int: + self.revocations.append((user_id, reason.value)) + return 2 + + +class FauxDepotAudit: + def __init__(self) -> None: + self.lignes: list[tuple[str, Any]] = [] + + async def record(self, *, action: object, detail: Any = None, **_: object) -> None: + self.lignes.append((str(action), detail)) + + +class FauxHacheur: + async def hash(self, password: str) -> str: + return "$argon2id$nouvelle" + + +class FausseTransaction: + async def commit(self) -> None: + return None + + +@dataclass +class Attirail: + service: UserService + comptes: FauxDepotComptes + jetons: FauxDepotJetons + audit: FauxDepotAudit + + +def fabrique( + compte: FauxCompte | None = None, *, admins_actifs: int = 2, existe: bool = False +) -> Attirail: + comptes = FauxDepotComptes(compte, admins_actifs=admins_actifs, existe=existe) + jetons = FauxDepotJetons() + audit = FauxDepotAudit() + service = UserService( + users=comptes, # type: ignore[arg-type] + refresh_tokens=jetons, # type: ignore[arg-type] + audit=audit, # type: ignore[arg-type] + hasher=FauxHacheur(), # type: ignore[arg-type] + transaction=FausseTransaction(), + ) + return Attirail(service, comptes, jetons, audit) + + +async def test_create_returns_a_temporary_password_shown_once() -> None: + attirail = fabrique() + + cree = await attirail.service.create( + actor=ADMIN, email="nouveau@enervision.fr", role=Role.LECTEUR, full_name=None + ) + + assert len(cree.temporary_password) >= 18 + assert attirail.comptes.crees == ["nouveau@enervision.fr"] + assert "user.created" in attirail.audit.lignes[0][0] + + +async def test_create_refuses_an_address_already_taken() -> None: + attirail = fabrique(FauxCompte(), existe=True) + + with pytest.raises(EmailAlreadyUsedError): + await attirail.service.create( + actor=ADMIN, email="lecteur@enervision.fr", role=Role.LECTEUR, full_name=None + ) + + +async def test_change_role_revokes_every_session_of_the_target() -> None: + cible = FauxCompte() + attirail = fabrique(cible) + + await attirail.service.change_role(actor=ADMIN, user_id=cible.id, role=Role.OPERATEUR) + + assert attirail.comptes.roles_poses == [(cible.id, "operateur")] + assert attirail.jetons.revocations == [(cible.id, RevocationReason.ADMINISTRATION.value)] + + +async def test_change_role_does_nothing_when_the_role_is_already_the_right_one() -> None: + cible = FauxCompte(role="operateur") + attirail = fabrique(cible) + + await attirail.service.change_role(actor=ADMIN, user_id=cible.id, role=Role.OPERATEUR) + + assert attirail.comptes.roles_poses == [] + assert attirail.jetons.revocations == [] + + +async def test_change_role_refuses_to_demote_the_last_active_administrator() -> None: + dernier = FauxCompte(role="admin") + attirail = fabrique(dernier, admins_actifs=1) + + with pytest.raises(LastAdminError): + await attirail.service.change_role(actor=ADMIN, user_id=dernier.id, role=Role.LECTEUR) + + +async def test_change_role_accepts_a_demotion_when_another_administrator_remains() -> None: + admin = FauxCompte(role="admin") + attirail = fabrique(admin, admins_actifs=2) + + await attirail.service.change_role(actor=ADMIN, user_id=admin.id, role=Role.LECTEUR) + + assert attirail.comptes.roles_poses == [(admin.id, "lecteur")] + + +async def test_set_active_refuses_to_disable_the_last_active_administrator() -> None: + dernier = FauxCompte(role="admin") + attirail = fabrique(dernier, admins_actifs=1) + + with pytest.raises(LastAdminError): + await attirail.service.set_active(actor=ADMIN, user_id=dernier.id, is_active=False) + + +async def test_set_active_revokes_the_sessions_when_disabling() -> None: + cible = FauxCompte() + attirail = fabrique(cible) + + await attirail.service.set_active(actor=ADMIN, user_id=cible.id, is_active=False) + + assert attirail.comptes.activations == [(cible.id, False)] + assert attirail.jetons.revocations == [(cible.id, RevocationReason.ADMINISTRATION.value)] + + +async def test_set_active_leaves_the_sessions_alone_when_enabling() -> None: + cible = FauxCompte(is_active=False) + attirail = fabrique(cible) + + await attirail.service.set_active(actor=ADMIN, user_id=cible.id, is_active=True) + + assert attirail.jetons.revocations == [] + + +async def test_reset_password_closes_every_session_and_forces_a_change() -> None: + cible = FauxCompte() + attirail = fabrique(cible) + + reinitialise = await attirail.service.reset_password(actor=ADMIN, user_id=cible.id) + + assert len(reinitialise.temporary_password) >= 18 + assert attirail.comptes.mots_de_passe == [cible.id] + assert attirail.jetons.revocations == [ + (cible.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value) + ] + + +@pytest.mark.parametrize( + "action", + ["change_role", "set_active", "reset_password"], + ids=["changement_de_role", "activation", "reinitialisation"], +) +async def test_every_operation_refuses_an_unknown_account(action: str) -> None: + attirail = fabrique(None) + arguments: dict[str, Any] = {"actor": ADMIN, "user_id": uuid4()} + if action == "change_role": + arguments["role"] = Role.ADMIN + if action == "set_active": + arguments["is_active"] = False + + with pytest.raises(UserNotFoundError): + await getattr(attirail.service, action)(**arguments) From e8f22bf42796d0e38ac617087955b761953998d0 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:56:17 +0200 Subject: [PATCH 09/13] =?UTF-8?q?feat(backend):=20durcit=20la=20surface=20?= =?UTF-8?q?expos=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En-têtes de sécurité, CORS resserré, caviardage des journaux, `/metrics` derrière un jeton facultatif, documentation fermée en préproduction, et la sonde de disponibilité cesse de publier la version de TimescaleDB. HSTS et CSP sont volontairement absents : l'application ignore si TLS termine devant elle, et une CSP sur une API JSON ne protège presque rien. Les deux appartiennent au terminateur TLS, celle qui compte protège la page Angular. `/metrics` est gardé par un jeton statique et non par un rôle : coupler la supervision au modèle d'utilisateurs casserait la collecte à chaque panne d'authentification, c'est-à-dire quand on en a le plus besoin. Le contrôle principal reste le réseau. Le caviardage est la troisième ligne de défense, pas la première. On ne passe aucun secret au logger et aucun jeton dans une URL ; le filtre rattrape ce que personne n'a relu, à commencer par l'écho SQL qui publiait les empreintes Argon2 quand `debug` est actif. Corrige un défaut que le test a révélé : `create_app(settings)` ne pilotait que la construction, les dépendances continuaient de lire `get_settings()` depuis l'environnement. Un test « en production » ne testait donc pas la production, et `TESTING.md` promet le contraire. --- apps/backend/app/api/middleware.py | 35 ++++++ apps/backend/app/api/security.py | 23 ++++ apps/backend/app/api/v1/endpoints/health.py | 3 +- apps/backend/app/core/logging.py | 44 ++++++++ apps/backend/app/main.py | 35 ++++-- apps/backend/app/schemas/health.py | 5 +- apps/backend/tests/api/test_hardening.py | 111 ++++++++++++++++++++ apps/backend/tests/api/test_health.py | 7 +- apps/backend/tests/core/test_logging.py | 72 +++++++++++++ 9 files changed, 323 insertions(+), 12 deletions(-) create mode 100644 apps/backend/app/api/middleware.py create mode 100644 apps/backend/app/api/security.py create mode 100644 apps/backend/tests/api/test_hardening.py create mode 100644 apps/backend/tests/core/test_logging.py diff --git a/apps/backend/app/api/middleware.py b/apps/backend/app/api/middleware.py new file mode 100644 index 0000000..0a01192 --- /dev/null +++ b/apps/backend/app/api/middleware.py @@ -0,0 +1,35 @@ +# Pourquoi : `SecurityHeadersMiddleware` ne pose ni HSTS ni CSP, et c'est délibéré. +# L'application ignore si TLS termine devant elle, donc elle ne peut pas décider d'un HSTS ; +# et une CSP sur une API JSON ne protège presque rien, celle qui compte protège la page +# Angular. Les deux appartiennent au terminateur TLS. +# Contrainte : `/docs` charge Swagger depuis un CDN, une CSP stricte ici casserait la +# documentation sans rien sécuriser. + +from collections.abc import Awaitable, Callable +from typing import Final + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +EN_TETES: Final[dict[str, str]] = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", +} + +PREFIXE_AUTHENTIFICATION: Final = "/auth" + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch( + self, request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: + response = await call_next(request) + for nom, valeur in EN_TETES.items(): + response.headers.setdefault(nom, valeur) + + # Une réponse d'authentification ne doit jamais être conservée par un intermédiaire. + if PREFIXE_AUTHENTIFICATION in request.url.path: + response.headers["Cache-Control"] = "no-store" + return response diff --git a/apps/backend/app/api/security.py b/apps/backend/app/api/security.py new file mode 100644 index 0000000..6b47646 --- /dev/null +++ b/apps/backend/app/api/security.py @@ -0,0 +1,23 @@ +# Pourquoi : `/metrics` est protégé par un jeton statique et non par un rôle applicatif. Coupler +# la supervision au modèle d'utilisateurs casserait la collecte à chaque panne +# d'authentification, c'est-à-dire précisément quand on a besoin des métriques. Le vrai contrôle +# reste le réseau : Prometheus scrute sur le réseau interne et `/metrics` ne sort pas. + +import secrets + +from fastapi import HTTPException, Request, status + +from app.api.deps import SettingsDep + + +def require_metrics_token(request: Request, settings: SettingsDep) -> None: + attendu = settings.metrics_token + if attendu is None: + return + + presente = request.headers.get("authorization", "") + prefixe = "Bearer " + if not presente.startswith(prefixe) or not secrets.compare_digest( + presente[len(prefixe) :], attendu.get_secret_value() + ): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Jeton requis") diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index f97caaf..bf6b2ee 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -40,4 +40,5 @@ async def readiness(session: SessionDep) -> ReadinessStatus: detail="Extension TimescaleDB absente", ) - return ReadinessStatus(status="ready", database="reachable", timescaledb=version) + logger.debug("Extension TimescaleDB en version %s", version) + return ReadinessStatus(status="ready", database="reachable", timescaledb="loaded") diff --git a/apps/backend/app/core/logging.py b/apps/backend/app/core/logging.py index c0cc9a6..14cac3e 100644 --- a/apps/backend/app/core/logging.py +++ b/apps/backend/app/core/logging.py @@ -1,8 +1,48 @@ +# Pourquoi : `RedactingFilter` est la troisième ligne de défense, pas la première. La première +# est de ne jamais passer un secret au logger, la deuxième de ne jamais mettre un jeton dans +# une URL, que le journal d'accès enregistrerait de toute façon. Le filtre rattrape l'erreur +# que personne n'a relue, notamment l'écho SQL quand `debug` est actif. + import logging +import re from logging.config import dictConfig +from typing import Final from app.core.config import Settings +CAVIARDAGE: Final = "[expurgé]" + +REMPLACEMENTS: Final[tuple[tuple[re.Pattern[str], str], ...]] = ( + (re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]{20,}=*"), f"Bearer {CAVIARDAGE}"), + (re.compile(r"eyJ[A-Za-z0-9._-]{20,}"), CAVIARDAGE), + (re.compile(r"\$argon2[a-z0-9]*\$\S+"), CAVIARDAGE), + ( + re.compile(r'("?(?:password|mot_de_passe|secret|token)"?\s*[:=]\s*")[^"]*(")'), + rf"\1{CAVIARDAGE}\2", + ), + ( + re.compile(r"((?:password|mot_de_passe|secret|token)[A-Za-z_]*=)[^&\s;\"]+"), + rf"\1{CAVIARDAGE}", + ), + (re.compile(r"(ev_refresh=)[^;\s]+"), rf"\1{CAVIARDAGE}"), +) + + +def redact(message: str) -> str: + for motif, remplacement in REMPLACEMENTS: + message = motif.sub(remplacement, message) + return message + + +class RedactingFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + message = record.getMessage() + expurge = redact(message) + if expurge != message: + record.msg = expurge + record.args = () + return True + def configure_logging(settings: Settings) -> None: formatter = "json" if settings.is_production else "console" @@ -10,6 +50,9 @@ def configure_logging(settings: Settings) -> None: { "version": 1, "disable_existing_loggers": False, + "filters": { + "redaction": {"()": "app.core.logging.RedactingFilter"}, + }, "formatters": { "console": { "format": "%(asctime)s %(levelname)-8s %(name)s %(message)s", @@ -23,6 +66,7 @@ def configure_logging(settings: Settings) -> None: "default": { "class": "logging.StreamHandler", "formatter": formatter, + "filters": ["redaction"], "stream": "ext://sys.stdout", }, }, diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 2ddf1dd..6c3c866 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -1,11 +1,13 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from prometheus_fastapi_instrumentator import Instrumentator from app.api.errors import register_error_handlers +from app.api.middleware import SecurityHeadersMiddleware +from app.api.security import require_metrics_token from app.api.v1.router import api_router from app.core.config import Settings, get_settings from app.core.logging import configure_logging, get_logger @@ -13,6 +15,9 @@ from app.db.session import get_engine logger = get_logger(__name__) +METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] +EN_TETES_AUTORISES = ["Authorization", "Content-Type"] + @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: @@ -28,30 +33,46 @@ def create_app(settings: Settings | None = None) -> FastAPI: resolved = settings or get_settings() configure_logging(resolved) + documentee = resolved.api_docs_are_exposed application = FastAPI( title=resolved.name, version=resolved.version, debug=resolved.debug, lifespan=lifespan, - docs_url=None if resolved.is_production else "/docs", - redoc_url=None if resolved.is_production else "/redoc", - openapi_url=None if resolved.is_production else "/openapi.json", + docs_url="/docs" if documentee else None, + redoc_url="/redoc" if documentee else None, + openapi_url="/openapi.json" if documentee else None, ) + application.add_middleware(SecurityHeadersMiddleware) + if resolved.allowed_origins: + # Méthodes et en-têtes listés plutôt que joker : avec `allow_credentials`, la liste + # d'origines devient l'unique contrôle, autant documenter le contrat exact. application.add_middleware( CORSMiddleware, allow_origins=resolved.allowed_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=METHODES_AUTORISEES, + allow_headers=EN_TETES_AUTORISES, + expose_headers=["Retry-After"], + max_age=600, ) register_error_handlers(application) Instrumentator().instrument(application).expose( - application, endpoint="/metrics", include_in_schema=False + application, + endpoint="/metrics", + include_in_schema=False, + dependencies=[Depends(require_metrics_token)], ) application.include_router(api_router, prefix=resolved.api_prefix) + # Piège : sans cette surcharge, une configuration passée à `create_app()` ne piloterait + # que la construction, et les dépendances continueraient de lire `get_settings()` depuis + # l'environnement. Un test « en production » ne testerait alors pas la production. + if settings is not None: + application.dependency_overrides[get_settings] = lambda: resolved + return application diff --git a/apps/backend/app/schemas/health.py b/apps/backend/app/schemas/health.py index e4ec86e..e7ddd4c 100644 --- a/apps/backend/app/schemas/health.py +++ b/apps/backend/app/schemas/health.py @@ -10,7 +10,10 @@ class LivenessStatus(BaseModel): environment: str +# Contrainte : la sonde ne publie pas la version de TimescaleDB. Une version exacte de +# composant, servie sans authentification, est de la reconnaissance gratuite pour qui +# cherche une CVE. Elle part dans le journal, où elle sert au diagnostic. class ReadinessStatus(BaseModel): status: Literal["ready"] database: Literal["reachable"] - timescaledb: str + timescaledb: Literal["loaded"] diff --git a/apps/backend/tests/api/test_hardening.py b/apps/backend/tests/api/test_hardening.py new file mode 100644 index 0000000..3ee1170 --- /dev/null +++ b/apps/backend/tests/api/test_hardening.py @@ -0,0 +1,111 @@ +import pytest +from httpx import ASGITransport, AsyncClient +from httpx import Response as HttpResponse + +from app.main import create_app +from tests.factories import make_settings + +ORIGINE = "https://enervision.fr" + + +async def interroge( + settings_overrides: dict[str, object], chemin: str, **kwargs: object +) -> HttpResponse: + application = create_app(make_settings(**settings_overrides)) + transport = ASGITransport(app=application) + async with AsyncClient(transport=transport, base_url="http://test") as client: + return await client.get(chemin, **kwargs) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + ("entete", "valeur"), + [ + ("x-content-type-options", "nosniff"), + ("x-frame-options", "DENY"), + ("referrer-policy", "no-referrer"), + ], + ids=["nosniff", "anti_iframe", "referrer"], +) +async def test_every_response_carries_the_security_headers( + client: AsyncClient, entete: str, valeur: str +) -> None: + response = await client.get("/api/v1/health/live") + + assert response.headers[entete] == valeur + + +async def test_the_application_never_sets_hsts_itself(client: AsyncClient) -> None: + response = await client.get("/api/v1/health/live") + + assert "strict-transport-security" not in response.headers + + +@pytest.mark.parametrize( + "env", + ["staging", "prod"], + ids=["preproduction", "production"], +) +async def test_the_documentation_disappears_outside_development(env: str) -> None: + surcharges = {"env": env, "cors_origins": ORIGINE} + + for chemin in ("/docs", "/openapi.json"): + assert (await interroge(surcharges, chemin)).status_code == 404 + + +@pytest.mark.parametrize("env", ["local", "dev"], ids=["local", "developpement"]) +async def test_the_documentation_stays_available_while_developing(env: str) -> None: + surcharges = {"env": env, "cors_origins": ORIGINE} + + assert (await interroge(surcharges, "/openapi.json")).status_code == 200 + + +async def test_an_explicit_override_can_reopen_the_documentation() -> None: + surcharges = {"env": "prod", "cors_origins": ORIGINE, "expose_api_docs": True} + + assert (await interroge(surcharges, "/openapi.json")).status_code == 200 + + +async def test_metrics_stay_open_when_no_token_is_configured(client: AsyncClient) -> None: + response = await client.get("/metrics") + + assert response.status_code == 200 + + +async def test_metrics_demand_the_token_once_one_is_configured() -> None: + surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"} + + assert (await interroge(surcharges, "/metrics")).status_code == 401 + + +async def test_metrics_answer_to_the_right_token() -> None: + surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"} + entetes = {"Authorization": "Bearer un-jeton-de-supervision-assez-long"} + + response = await interroge(surcharges, "/metrics", headers=entetes) + + assert response.status_code == 200 + + +async def test_metrics_refuse_a_token_that_is_almost_right() -> None: + surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"} + entetes = {"Authorization": "Bearer un-jeton-de-supervision-assez-lon"} + + response = await interroge(surcharges, "/metrics", headers=entetes) + + assert response.status_code == 401 + + +async def test_an_unhandled_error_returns_a_correlation_id_and_no_traceback() -> None: + application = create_app(make_settings()) + + @application.get("/api/v1/essai-panne") + async def _casse() -> None: + raise RuntimeError("secret interne de la pile") + + transport = ASGITransport(app=application, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/essai-panne") + + assert response.status_code == 500 + assert "secret interne de la pile" not in response.text + assert response.json()["correlation"] diff --git a/apps/backend/tests/api/test_health.py b/apps/backend/tests/api/test_health.py index d5ba9bc..b9f2f33 100644 --- a/apps/backend/tests/api/test_health.py +++ b/apps/backend/tests/api/test_health.py @@ -17,7 +17,7 @@ async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None: } -async def test_readiness_reports_the_timescaledb_version( +async def test_readiness_confirms_the_extension_without_leaking_its_version( fake_session: Callable[..., None], client: AsyncClient ) -> None: fake_session(result="2.22.1") @@ -28,8 +28,9 @@ async def test_readiness_reports_the_timescaledb_version( assert response.json() == { "status": "ready", "database": "reachable", - "timescaledb": "2.22.1", + "timescaledb": "loaded", } + assert "2.22.1" not in response.text async def test_readiness_returns_503_when_the_extension_is_missing( @@ -75,4 +76,4 @@ async def test_readiness_reaches_the_real_database(client: AsyncClient) -> None: body = response.json() assert body["status"] == "ready" assert body["database"] == "reachable" - assert body["timescaledb"] + assert body["timescaledb"] == "loaded" diff --git a/apps/backend/tests/core/test_logging.py b/apps/backend/tests/core/test_logging.py new file mode 100644 index 0000000..191c0c8 --- /dev/null +++ b/apps/backend/tests/core/test_logging.py @@ -0,0 +1,72 @@ +import logging + +import pytest + +from app.core.logging import CAVIARDAGE, RedactingFilter, redact + + +@pytest.mark.parametrize( + "message", + [ + "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.charge-utile-assez-longue.signature", + "jeton brut eyJhbGciOiJIUzI1NiJ9abcdefghijklmnopqrstuvwxyz", + "INSERT ... ('$argon2id$v=19$m=19456,t=2,p=1$sel-en-clair$empreinte-en-clair')", + '{"password": "le-mot-de-passe-du-client"}', + "current_password=le-mot-de-passe", + "Cookie: ev_refresh=abcdefghijklmnopqrstuvwxyz0123456789", + ], + ids=[ + "en_tete_bearer", + "jeton_jwt_nu", + "empreinte_argon2", + "mot_de_passe_json", + "mot_de_passe_en_paire", + "cookie_de_rafraichissement", + ], +) +def test_redact_removes_every_known_secret_shape(message: str) -> None: + expurge = redact(message) + + assert CAVIARDAGE in expurge + for suspect in ("le-mot-de-passe", "empreinte-en-clair", "abcdefghijklmnopqrstuvwxyz"): + assert suspect not in expurge + + +def test_redact_leaves_an_innocent_message_untouched() -> None: + message = "auth.login.success user_id=3f2a ip=203.0.113.10" + + assert redact(message) == message + + +def test_the_filter_rewrites_the_record_before_it_reaches_the_handler() -> None: + enregistrement = logging.LogRecord( + name="app", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg='requete {"password": "%s"}', + args=("secret-du-client",), + exc_info=None, + ) + + conserve = RedactingFilter().filter(enregistrement) + + assert conserve is True + assert "secret-du-client" not in enregistrement.getMessage() + + +def test_the_filter_keeps_a_record_that_holds_no_secret() -> None: + enregistrement = logging.LogRecord( + name="app", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="requete %s", + args=("/api/v1/health/live",), + exc_info=None, + ) + + conserve = RedactingFilter().filter(enregistrement) + + assert conserve is True + assert enregistrement.getMessage() == "requete /api/v1/health/live" From cb2ac8c2c2faa69c06941d828f2a95243c8b0d20 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:57:42 +0200 Subject: [PATCH 10/13] =?UTF-8?q?test(backend):=20=C3=A9prouve=20le=20parc?= =?UTF-8?q?ours=20d'authentification=20contre=20la=20vraie=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six scénarios bout en bout, sans serveur ni port ouvert : connexion, rotation, déconnexion, rejeu d'un cookie déjà tourné, révocation immédiate et enregistrement d'une tentative sur adresse inconnue. Le scénario du rejeu vérifie aussi que la session encore vivante tombe avec sa famille : c'est la propriété qui distingue la détection de la simple rotation, et elle ne se démontre pas sur un double. Corrige un défaut que ce parcours a révélé : `iat` est une date JWT, donc en secondes entières, et `datetime.fromtimestamp` tronque. Tout jeton émis dans la même seconde que `credentials_changed_at` était rejeté, ce qui aurait déconnecté l'appareil courant à chaque changement de mot de passe, exactement l'inverse de ce que `/auth/password` promet. --- apps/backend/app/api/deps.py | 5 +- .../api/test_parcours_authentification.py | 162 ++++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 apps/backend/tests/api/test_parcours_authentification.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 7943225..4dc32cb 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -149,7 +149,10 @@ async def get_current_principal( compte = await UserRepository(session).get_by_id(claims.subject) if compte is None or not compte.is_active: raise _non_authentifie("invalid_token") - if claims.issued_at < compte.credentials_changed_at: + # Piège : `iat` est une date JWT, donc en secondes entières. Comparer sans tronquer le + # marqueur rejetterait tout jeton émis dans la même seconde que le changement, c'est-à-dire + # celui que `/auth/password` vient de rendre pour garder l'appareil courant connecté. + if int(claims.issued_at.timestamp()) < int(compte.credentials_changed_at.timestamp()): raise _non_authentifie("token_stale") if claims.role != compte.role: raise _non_authentifie("token_stale") diff --git a/apps/backend/tests/api/test_parcours_authentification.py b/apps/backend/tests/api/test_parcours_authentification.py new file mode 100644 index 0000000..e5e4cce --- /dev/null +++ b/apps/backend/tests/api/test_parcours_authentification.py @@ -0,0 +1,162 @@ +# Parcours complet contre la vraie base, sans serveur ni port ouvert. C'est ce fichier qui +# prouve que le câblage tient : la connexion, la rotation, la détection de réutilisation et la +# révocation immédiate passent par les vrais dépôts, les vraies transactions et les vrais +# déclencheurs PostgreSQL. + +import uuid +from collections.abc import AsyncIterator + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import text + +from app.core.hashing import build_hasher +from app.core.roles import Role +from app.db.session import get_session_factory +from app.repositories.user import UserRepository + +pytestmark = pytest.mark.integration + +MOT_DE_PASSE = "un-mot-de-passe-de-recette" + + +@pytest.fixture +async def compte_operateur() -> AsyncIterator[str]: + email = f"parcours-{uuid.uuid4().hex[:12]}@enervision.fr" + hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2) + empreinte = await hacheur.hash(MOT_DE_PASSE) + + async with get_session_factory()() as session: + await UserRepository(session).create( + email=email, password_hash=empreinte, role=Role.OPERATEUR + ) + await session.commit() + + yield email + + async with get_session_factory()() as session: + await session.execute(text("delete from app_user where email = :e"), {"e": email}) + await session.commit() + + +@pytest.fixture +async def navigateur(app: FastAPI) -> AsyncIterator[AsyncClient]: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +async def connecte(navigateur: AsyncClient, email: str) -> dict[str, str]: + reponse = await navigateur.post( + "/api/v1/auth/login", json={"email": email, "password": MOT_DE_PASSE} + ) + assert reponse.status_code == 200, reponse.text + return {"Authorization": f"Bearer {reponse.json()['access_token']}"} + + +async def test_a_full_session_runs_from_login_to_logout( + compte_operateur: str, navigateur: AsyncClient +) -> None: + entetes = await connecte(navigateur, compte_operateur) + + identite = await navigateur.get("/api/v1/auth/me", headers=entetes) + rotation = await navigateur.post("/api/v1/auth/refresh") + deconnexion = await navigateur.post("/api/v1/auth/logout") + + assert identite.status_code == 200 + assert identite.json()["role"] == "operateur" + assert rotation.status_code == 200 + assert deconnexion.status_code == 204 + + +async def test_replaying_a_rotated_cookie_kills_the_whole_family( + compte_operateur: str, navigateur: AsyncClient +) -> None: + await connecte(navigateur, compte_operateur) + vole = navigateur.cookies["ev_refresh"] + premiere_rotation = await navigateur.post("/api/v1/auth/refresh") + vivant = navigateur.cookies["ev_refresh"] + + navigateur.cookies.set("ev_refresh", vole) + rejeu = await navigateur.post("/api/v1/auth/refresh") + + navigateur.cookies.set("ev_refresh", vivant) + apres = await navigateur.post("/api/v1/auth/refresh") + + assert premiere_rotation.status_code == 200 + assert rejeu.status_code == 401 + assert apres.status_code == 401, "la session vivante doit tomber avec sa famille" + + +async def test_the_reuse_leaves_a_trace_in_the_append_only_audit_log( + compte_operateur: str, navigateur: AsyncClient +) -> None: + await connecte(navigateur, compte_operateur) + vole = navigateur.cookies["ev_refresh"] + await navigateur.post("/api/v1/auth/refresh") + + navigateur.cookies.set("ev_refresh", vole) + await navigateur.post("/api/v1/auth/refresh") + + async with get_session_factory()() as session: + traces = await session.scalar( + text("select count(*) from audit_log where action = 'auth.refresh_reuse_detected'") + ) + assert traces is not None + assert traces >= 1 + + +async def test_disabling_an_account_invalidates_its_access_token_at_once( + compte_operateur: str, navigateur: AsyncClient +) -> None: + entetes = await connecte(navigateur, compte_operateur) + avant = await navigateur.get("/api/v1/auth/me", headers=entetes) + + async with get_session_factory()() as session: + depot = UserRepository(session) + compte = await depot.get_by_email(compte_operateur) + assert compte is not None + await depot.set_active(compte.id, is_active=False) + await session.commit() + + apres = await navigateur.get("/api/v1/auth/me", headers=entetes) + + assert avant.status_code == 200 + assert apres.status_code == 401, "la révocation doit être immédiate, pas dans 15 minutes" + + +async def test_changing_a_role_invalidates_the_token_that_still_carries_the_old_one( + compte_operateur: str, navigateur: AsyncClient +) -> None: + entetes = await connecte(navigateur, compte_operateur) + + async with get_session_factory()() as session: + depot = UserRepository(session) + compte = await depot.get_by_email(compte_operateur) + assert compte is not None + await depot.set_role(compte.id, Role.LECTEUR) + await session.commit() + + apres = await navigateur.get("/api/v1/auth/me", headers=entetes) + + assert apres.status_code == 401 + assert "token_stale" in apres.headers["www-authenticate"] + + +async def test_a_failed_login_is_recorded_even_for_an_unknown_address( + navigateur: AsyncClient, +) -> None: + inconnu = f"inconnu-{uuid.uuid4().hex[:12]}@enervision.fr" + + reponse = await navigateur.post( + "/api/v1/auth/login", json={"email": inconnu, "password": "peu-importe-ici"} + ) + + async with get_session_factory()() as session: + tentatives = await session.scalar( + text("select count(*) from login_attempt where email_tried = :e"), {"e": inconnu} + ) + assert reponse.status_code == 401 + assert reponse.json() == {"detail": "Identifiants invalides"} + assert tentatives == 1, "sans cette ligne, le 429 deviendrait un oracle d'existence" From c60081a5ac4688f61dbb88bf29da617f124d37b4 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:58:15 +0200 Subject: [PATCH 11/13] test(backend): isole les tests d'audit par cible unique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ils interrogeaient `audit_log` sans filtre, ce qui supposait une table vide. Le parcours d'authentification y écrit désormais de vraies lignes, et comme la table est en ajout seul, elles ne s'effacent pas entre deux exécutions. Chaque test filtre maintenant sur son propre `target_id`. --- .../tests/repositories/test_audit_log.py | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/backend/tests/repositories/test_audit_log.py b/apps/backend/tests/repositories/test_audit_log.py index 1c6fc64..beacc8e 100644 --- a/apps/backend/tests/repositories/test_audit_log.py +++ b/apps/backend/tests/repositories/test_audit_log.py @@ -56,12 +56,17 @@ async def test_the_database_refuses_to_mutate_the_audit_log( async def test_record_keeps_a_snapshot_of_the_actor(session: AsyncSession) -> None: depot = AuditLogRepository(session) + cible = uuid.uuid4().hex - await depot.record(action=AuditAction.COMPTE_DESACTIVE, actor=ACTEUR) + await depot.record(action=AuditAction.COMPTE_DESACTIVE, actor=ACTEUR, target_id=cible) await session.flush() ligne = ( await session.execute( - text("select actor_id, actor_email, actor_role, outcome from audit_log") + text( + "select actor_id, actor_email, actor_role, outcome from audit_log " + "where target_id = :c" + ), + {"c": cible}, ) ).one() await session.rollback() @@ -77,9 +82,15 @@ async def test_record_accepts_a_label_when_there_is_no_authenticated_actor( ) -> None: depot = AuditLogRepository(session) - await depot.record(action=AuditAction.ADMIN_AMORCE, actor_label="cli") + cible = uuid.uuid4().hex + await depot.record(action=AuditAction.ADMIN_AMORCE, actor_label="cli", target_id=cible) await session.flush() - ligne = (await session.execute(text("select actor_id, actor_email from audit_log"))).one() + ligne = ( + await session.execute( + text("select actor_id, actor_email from audit_log where target_id = :c"), + {"c": cible}, + ) + ).one() await session.rollback() assert ligne.actor_id is None @@ -91,13 +102,19 @@ async def test_record_drops_the_detail_keys_outside_the_allow_list( ) -> None: depot = AuditLogRepository(session) + cible = uuid.uuid4().hex await depot.record( action=AuditAction.COMPTE_ROLE_CHANGE, actor=ACTEUR, + target_id=cible, 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() + detail = ( + await session.execute( + text("select detail from audit_log where target_id = :c"), {"c": cible} + ) + ).scalar_one() await session.rollback() assert detail == {"role_avant": "lecteur"} From 3b7383697e6574467b4d6ddd2bd02bfac57d2ca2 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 15:05:28 +0200 Subject: [PATCH 12/13] =?UTF-8?q?docs:=20acte=20les=20d=C3=A9cisions=20d'a?= =?UTF-8?q?uthentification=20et=20met=20=C3=A0=20jour=20les=20vues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois ADR : le jeton d'accès et le rafraîchissement opaque, le RBAC avec relecture du compte à chaque requête, et le journal d'audit en ajout seul. Chacun porte ses alternatives écartées et son critère de bascule, notamment celui vers OIDC. `31-contrat-authentification.md` est destiné au frontend : endpoints, codes d'erreur à traiter, et les quatre règles qui comptent. La troisième, un seul rafraîchissement en vol, est une exigence et non une optimisation : cinq rotations concurrentes seraient lues comme un rejeu et révoqueraient la session à chaque chargement de page. `owasp-traceabilite.md` remplace la revendication « couverture OWASP Top 10 et API Top 10 » de la NFR4, qui n'a pas de réponse honnête sur vingt items en deux semaines. Un contrôle par ligne, l'item adressé, et une section qui dit ce qui reste ouvert : portée par site, bornage des lectures de séries, transport, et la consommation de l'API Mock. Les vues 00, 20 et 40 suivent, comme l'impose leur propre règle de maintenance. La question ouverte « quel mécanisme d'authentification » est fermée ; trois autres la remplacent, dont la portée par site. --- README.md | 2 +- apps/backend/README.md | 56 +++++-- apps/backend/TESTING.md | 32 ++++ docs/README.md | 13 +- ...-authentification-jwt-et-refresh-opaque.md | 128 +++++++++++++++ .../0003-autorisation-rbac-a-trois-roles.md | 107 ++++++++++++ .../adr/0004-journal-d-audit-en-ajout-seul.md | 104 ++++++++++++ docs/architecture/00-vue-ensemble.md | 50 ++++-- docs/architecture/20-backend.md | 152 ++++++++++++++---- .../31-contrat-authentification.md | 144 +++++++++++++++++ docs/architecture/40-data.md | 74 ++++++++- docs/architecture/README.md | 13 +- docs/architecture/owasp-traceabilite.md | 70 ++++++++ 13 files changed, 883 insertions(+), 62 deletions(-) create mode 100644 docs/adr/0002-authentification-jwt-et-refresh-opaque.md create mode 100644 docs/adr/0003-autorisation-rbac-a-trois-roles.md create mode 100644 docs/adr/0004-journal-d-audit-en-ajout-seul.md create mode 100644 docs/architecture/31-contrat-authentification.md create mode 100644 docs/architecture/owasp-traceabilite.md diff --git a/README.md b/README.md index 20181cc..7a8147f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | ETL | Apache Airflow | `etl/airflow` | A initialiser | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | -| CI/CD | GitHub Actions | `.github/workflows` | A initialiser | +| CI/CD | GitHub Actions | `.github/workflows` | Backend en place | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend diff --git a/apps/backend/README.md b/apps/backend/README.md index 5d940b8..12fd9ba 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -57,13 +57,21 @@ independants de l'environnement. ``` app/ ├── api/ -│ ├── deps.py Dependances FastAPI partagees (session, settings) +│ ├── deps.py Dépendances partagées : session, settings, principal, gardes de rôle +│ ├── errors.py Gestionnaires 422 et 500 +│ ├── middleware.py En-têtes de sécurité +│ ├── security.py Garde du point /metrics │ └── v1/ -│ ├── router.py Agregation des routes de la version 1 -│ └── endpoints/ Un module par ressource exposee +│ ├── router.py Agrégation des routes de la version 1 +│ └── endpoints/ Un module par ressource exposée ├── core/ │ ├── config.py Settings Pydantic, source unique de configuration -│ └── logging.py Journalisation console en local, JSON en production +│ ├── cookies.py Attributs du cookie de rafraîchissement +│ ├── hashing.py Argon2id, poussé dans un fil sous limiteur +│ ├── logging.py Journalisation console en local, JSON en production +│ ├── principal.py L'identité que voit le code métier +│ ├── roles.py Rôles ordonnés +│ └── security.py Encodage et décodage des jetons d'accès ├── db/ │ ├── base.py Base declarative SQLAlchemy │ └── session.py Engine et sessions asynchrones @@ -71,6 +79,7 @@ app/ ├── schemas/ Modeles Pydantic d'entree et de sortie ├── repositories/ Acces aux donnees, une classe par agregat ├── services/ Regles metier, orchestrent les repositories +├── cli.py Commandes hors HTTP, dont l'amorcage du premier admin └── main.py Factory applicative tests/ Miroir de app/ alembic/ Migrations du schema applicatif @@ -81,12 +90,39 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie ## Routes -| Route | Role | -|------------------------|-------------------------------------------------| -| `/api/v1/health/live` | Sonde de vivacite, aucune dependance externe | -| `/api/v1/health/ready` | Sonde de disponibilite, verifie la base et TimescaleDB | -| `/metrics` | Metriques au format Prometheus | -| `/docs`, `/openapi.json` | Documentation, desactivee quand `APP_ENV=prod` | +| Route | Rôle | Accès | +|---|---|---| +| `/api/v1/health/live` | Sonde de vivacité, aucune dépendance externe | public | +| `/api/v1/health/ready` | Sonde de disponibilité, vérifie la base et TimescaleDB | public | +| `/api/v1/auth/login` | Ouvre une session | public | +| `/api/v1/auth/refresh` | Fait tourner la session | cookie | +| `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente | +| `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton | +| `/api/v1/auth/password` | Change son propre mot de passe | jeton | +| `/api/v1/auth/me` | Décrit le compte connecté | jeton | +| `/api/v1/users` | Liste et crée des comptes | `admin` | +| `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` | +| `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` | +| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` | +| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon | + +Le contrat détaillé pour le frontend est dans +[`docs/architecture/31-contrat-authentification.md`](../../docs/architecture/31-contrat-authentification.md). + +## Premier administrateur + +Aucun compte n'existe après les migrations. Il s'en crée un en ligne de commande : + +```bash +make bootstrap-admin EMAIL=prenom.nom@enervision.fr # mot de passe saisi au clavier +# ou, depuis apps/backend : +uv run python -m app.cli create-admin --email prenom.nom@enervision.fr --generate +``` + +Le compte est créé avec `must_change_password`, donc la première connexion ne donne accès qu'à +`/auth/me` et `/auth/password` jusqu'au changement. Le mot de passe ne transite jamais par +`argv`, visible de tout `ps`, et aucune révision Alembic n'insère de compte : son empreinte +resterait dans Git pour toujours. ## Migrations diff --git a/apps/backend/TESTING.md b/apps/backend/TESTING.md index f794421..e0daf47 100644 --- a/apps/backend/TESTING.md +++ b/apps/backend/TESTING.md @@ -141,3 +141,35 @@ make check # lint + typage + suite unitaire uv run pytest tests/api/test_health.py # un seul fichier uv run pytest -k readiness # par motif de nom ``` + +## Trois fichiers à connaître avant de toucher à l'authentification + +`tests/api/test_route_protection.py` interroge réellement chaque route sans identifiant et +échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Il n'inspecte pas l'arbre de +dépendances : celui-ci 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. **Rendre une route publique impose +donc de modifier la liste `ROUTES_PUBLIQUES` de ce fichier**, ce qui apparaît en clair dans la +diff d'une pull request. + +`tests/services/test_auth.py` donne au faux hacheur un **compteur d'appels**. C'est ce qui rend +possibles les deux assertions qui prouvent la conception, et qu'aucune autre forme de test +n'atteint : + +- adresse inconnue → le compteur vaut 1, donc le haché leurre a bien été vérifié et il n'y a pas + d'oracle temporel ; +- limite de débit atteinte → le compteur vaut 0, donc la limite est évaluée avant Argon2. + +`tests/api/test_parcours_authentification.py` joue six parcours complets contre la vraie base, +sous le marqueur `integration`, sans serveur ni port ouvert. C'est là que se démontrent +l'atomicité de la rotation, la mort de la famille au rejeu d'un cookie déjà tourné, et la +révocation immédiate d'un compte désactivé. + +## Deux pièges d'écriture de test + +**Lire les attributs avant le `rollback`.** Un `session.rollback()` périme les attributs chargés, +et les relire déclenche une entrée-sortie hors du contexte greenlet, donc un `MissingGreenlet`. +On capture la valeur dans une variable locale avant d'annuler. + +**`audit_log` ne se nettoie pas.** La table est en ajout seul, garanti par déclencheur : un test +ne peut pas effacer ce qu'il y écrit, et les lignes d'une exécution précédente sont encore là. +Chaque test filtre donc sur son propre `target_id` plutôt que de supposer une table vide. diff --git a/docs/README.md b/docs/README.md index 938a78a..17859f5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,13 @@ # Documentation -- `adr` : decisions d'architecture, une par fichier, numerotees et immuables. -- `architecture` : les vues du systeme. Point d'entree : [architecture/README.md](architecture/README.md). +- `adr` : décisions d'architecture, une par fichier, numérotées et immuables. +- `architecture` : les vues du système. Point d'entrée : [architecture/README.md](architecture/README.md). + +## Décisions en vigueur + +| ADR | Sujet | +|---|---| +| [0001](adr/0001-postgresql-timescaledb.md) | PostgreSQL avec l'extension TimescaleDB | +| [0002](adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque | +| [0003](adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, relecture du compte à chaque requête | +| [0004](adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL | diff --git a/docs/adr/0002-authentification-jwt-et-refresh-opaque.md b/docs/adr/0002-authentification-jwt-et-refresh-opaque.md new file mode 100644 index 0000000..8e568f5 --- /dev/null +++ b/docs/adr/0002-authentification-jwt-et-refresh-opaque.md @@ -0,0 +1,128 @@ +# 0002 - Authentification par JWT d'accès et jeton de rafraîchissement opaque + +- Statut : accepté +- Date : 2026-09-15 + +## Contexte + +L'école n'impose aucun mécanisme d'authentification : les choix techniques sont libres et +doivent être justifiés. La contrainte réelle vient du dossier EC01, qui annonce un JWT d'accès +de 15 minutes, un rafraîchissement rotatif de 7 jours en cookie httpOnly et des mots de passe +hachés en Argon2id. + +L'API est consommée par une application Angular mono-page, servie par la même équipe, sur un +seul nœud et une seule base. Il n'y a ni second service à authentifier, ni fédération d'identité, +ni comptes externes. + +## Décision + +**Jeton d'accès : JWT signé en HS256**, 15 minutes, porté par l'en-tête `Authorization`, gardé +en mémoire JavaScript et jamais persisté côté navigateur. + +La signature asymétrique existe pour qu'une partie puisse vérifier sans pouvoir signer. Ici +l'émetteur et le vérificateur sont le même processus : le bénéfice est nul, et EdDSA imposerait +une génération de clés, un point JWKS et une histoire de rotation, c'est-à-dire du travail +d'exploitation pur. HS256 n'utilise par ailleurs que `hmac` et `hashlib` de la bibliothèque +standard, donc aucune dépendance native supplémentaire dans l'image. + +Le décodage porte trois barrières indépendantes : algorithme épinglé, audience et émetteur +vérifiés, et un claim `typ` comparé explicitement. + +**Jeton de rafraîchissement : chaîne opaque de 256 bits, jamais un JWT.** Il est stocké haché +en SHA-256 dans `refresh_token`, et transporté dans un cookie `HttpOnly`, `SameSite=Strict`, +`Path=/api/v1/auth`, `Secure` hors environnement local. + +Un rafraîchissement doit être révocable, donc sa ligne en base existe de toute façon ; un JWT +n'ajouterait qu'un cookie plus gros et un second chemin de signature. Surtout, la séparation +d'avec le jeton d'accès devient **structurelle et non conditionnelle** : un JWT ne figure dans +aucune ligne, une chaîne opaque échoue au décodage. La confusion refresh-vers-accès, qui +transforme silencieusement une fenêtre de 15 minutes en fenêtre de 7 jours, devient impossible +même si quelqu'un oublie le test. + +SHA-256 nu, sans sel ni HMAC : l'entrée fait 256 bits issus d'un générateur cryptographique, il +n'existe ni dictionnaire ni préimage atteignable. Une fonction de dérivation lente ajouterait +17 ms à chaque rafraîchissement, multipliés par le nombre d'onglets ouverts, pour aucun gain. + +**Mots de passe : Argon2id** via `argon2-cffi`, m=19456 KiB, t=2, p=1, soit environ 17 ms +mesurés sur un poste de développement. Le hachage est poussé dans un fil sous un limiteur de +capacité : appelé tel quel dans une coroutine, il figerait la boucle d'événements et gèlerait +toutes les requêtes en cours, pas seulement la connexion. + +**Rotation avec détection de réutilisation.** Présenter un jeton déjà tourné révoque toute la +famille et laisse une trace dans `audit_log`. Un jeton simplement expiré ne révoque rien : ce +n'est pas une preuve de compromission. + +**Pas de verrouillage de compte.** Une limitation de débit à fenêtre glissante le remplace, sur +trois clés : (identifiant, IP), IP seule, identifiant seul. + +## Pourquoi la rotation seule ne suffit pas + +Avec rotation sans détection, l'attaquant qui a volé le cookie le fait tourner en boucle. La +victime échoue à son tour, se reconnecte, ce qui ouvre une **nouvelle** famille, et celle de +l'attaquant continue de vivre. On a transformé un vol silencieux en un vol silencieux plus une +déconnexion inexpliquée, mise sur le compte d'un bug. + +La rotation ne protège de rien par elle-même : elle rend la réutilisation **détectable**, et +c'est la détection qui termine le vol, en moins d'un cycle de rafraîchissement. + +Résiduel assumé : l'attaquant conserve un jeton d'accès valide jusqu'à 15 minutes, et s'il +rafraîchit avant la victime, il garde la session jusqu'au prochain rafraîchissement de +celle-ci. Borné, pas nul. + +## Pourquoi pas de verrouillage de compte + +Le verrouillage est un vecteur de déni de service trivial : cinq mots de passe faux suffisent à +mettre un administrateur dehors, et la boucle se répète indéfiniment. Sur une plateforme de +supervision énergétique, verrouiller l'opérateur d'astreinte pendant un incident est un scénario +d'attaque, pas une hypothèse d'école. + +Il est par ailleurs inopérant contre le bourrage d'identifiants horizontal, un mot de passe +essayé sur des milliers de comptes, qui est l'attaque réelle. Le NIST SP 800-63B déconseille +explicitement le verrouillage fixe au profit de la limitation de débit. + +Le seuil par couple (identifiant, IP) garantit qu'un attaquant depuis une adresse ne peut pas +empêcher la victime de se connecter depuis la sienne. Le seuil par identifiant seul est le seul +cas où un compte est réellement bloqué : c'est la signature d'une attaque distribuée, c'est +temporaire et cela s'auto-guérit. + +## Conséquences + +- Le rechargement de page perd le jeton d'accès. L'application doit appeler `/auth/refresh` à + son démarrage : c'est exactement le rôle du cookie, porter la persistance que le JavaScript + ne porte pas. +- L'intercepteur HTTP doit garantir **un seul rafraîchissement en vol**. Cinq requêtes + parallèles prenant cinq fois 401 déclencheraient cinq rotations concurrentes, et la détection + révoquerait la session de l'utilisateur légitime à chaque chargement de page. Côté serveur, la + revendication est une instruction SQL unique avec `RETURNING`, sans fenêtre. +- `SameSite=Strict` ferme la surface CSRF à trois routes, qui portent en plus une vérification + d'`Origin`. Le jour où un flux OIDC arrive, il faudra repasser à `Lax`. +- Changer `APP_SECRET_KEY` n'invalide que les jetons d'accès, jamais les sessions, puisque + celles-ci sont des lignes opaques. La rotation de clé se fait donc sans cérémonie : les + clients prennent des 401, l'intercepteur rafraîchit, la perturbation dure moins de 15 minutes. +- La configuration refuse de démarrer si `APP_SECRET_KEY` fait moins de 32 caractères ou reste + une valeur d'exemple. + +## Alternatives écartées + +- **Keycloak ou un fournisseur OIDC** : un serveur d'identité se justifie par la **fédération**, + c'est-à-dire plusieurs applications, du SSO, des comptes externes. Il y a une application et + des comptes internes. Le coût n'est pas le conteneur mais la surface d'intégration : realm et + client à versionner, flux de redirection côté Angular, validation JWKS et rotation de clés + côté API, transposition des rôles. Deux à trois jours sur un budget de dix. + **Critère de bascule** : l'exigence de SSO d'un client pilote. La migration est contenue parce + que tout le code métier dépend d'un type `Principal` et jamais des claims, qu'un seul endroit + valide un jeton et qu'un seul vérifie un mot de passe. +- **Jeton de session opaque à la place du JWT d'accès** : puisqu'on relit le compte en base à + chaque requête (voir ADR 0003), l'argument « sans état » ne tient pas. Un jeton opaque serait + défendable. Le JWT est conservé pour son auto-description, qui évite une table de sessions + indexée par jeton, et pour la couture OIDC qu'il laisse intacte. +- **Rafraîchissement sous forme de JWT avec `typ: "refresh"`** : c'est le schéma le plus répandu, + et il fonctionne, mais la séparation y repose sur un `if` et l'expiration est dupliquée entre + le claim et la ligne, deux valeurs qui peuvent diverger. +- **Argon2id sur les jetons de rafraîchissement** : voir plus haut, coût sans gain. +- **Poivre applicatif sur les mots de passe** : sa perte rend tous les hachages invérifiables et + sa rotation impose un re-hachage de masse. Sur deux semaines, le risque dépasse le gain. +- **`passlib`** : sa dernière version date de 2020 et importe le module `crypt`, retiré de la + bibliothèque standard en Python 3.13. Éliminatoire sur Python 3.14. +- **`python-jose`** : maintenance erratique et CVE en 2024. `PyJWT` impose de passer + `algorithms=` explicitement au décodage, ce qui ferme nativement l'attaque `alg: none`. diff --git a/docs/adr/0003-autorisation-rbac-a-trois-roles.md b/docs/adr/0003-autorisation-rbac-a-trois-roles.md new file mode 100644 index 0000000..28de6f0 --- /dev/null +++ b/docs/adr/0003-autorisation-rbac-a-trois-roles.md @@ -0,0 +1,107 @@ +# 0003 - Autorisation RBAC à trois rôles, avec relecture du compte à chaque requête + +- Statut : accepté +- Date : 2026-09-15 + +## Contexte + +Le dossier EC01 annonce un RBAC à trois rôles, `admin`, `opérateur` et `lecteur`, et des comptes +machine à machine distincts pour l'ETL et le travail d'apprentissage. Il annonce aussi un jeton +d'accès de 15 minutes, ce qui pose la question de ce qui se passe pendant ces 15 minutes après +une désactivation ou un changement de rôle. + +## Décision + +**Trois rôles totalement ordonnés** : `lecteur < operateur < admin`. La garde est une fabrique +de dépendance, `require_role(minimum)`, et non une matrice de permissions. + +Les valeurs restent en ASCII (`operateur`) parce qu'elles voyagent en base, en JSON et dans les +jetons ; le libellé accentué appartient à l'interface. + +**Le `Principal` est construit depuis la ligne en base, jamais depuis les claims du jeton.** +`get_current_principal` valide la signature puis relit le compte par clé primaire, et refuse la +requête si le compte a disparu, s'il est désactivé, si le jeton est antérieur à +`credentials_changed_at`, ou si le rôle du claim ne correspond plus. + +**Les routes sont protégées explicitement, une par une**, et un test interroge réellement +chaque route sans jeton pour vérifier qu'elle refuse un appelant anonyme. + +**Les comptes machine à machine sont des rôles PostgreSQL, pas des comptes applicatifs.** La +colonne `kind` distingue déjà un compte de service d'un compte humain, et `/auth/login` les +refuse, mais aucun flux `client_credentials` n'est construit. + +## Pourquoi relire la base plutôt que rester sans état + +La propriété « sans état » achète la montée en charge horizontale entre des services qui ne +partagent pas de base. Il y a un service et une base : le bénéfice est nul. + +Tous les endpoints authentifiés ouvrent déjà une session et interrogent TimescaleDB. Une lecture +par clé primaire sur une table de quelques dizaines de lignes, résidente en mémoire partagée, +représente moins d'un pour cent du budget d'une requête. + +Ce qu'on achète en échange est la **révocation immédiate**. « Un opérateur licencié à 10h00 +garde-t-il ses droits jusqu'à 10h15 ? » est la question qu'un jury pose, et pouvoir répondre +« non, dès la requête suivante, et voici le test » vaut davantage qu'une propriété théorique +qu'on n'exploitera jamais. + +Le claim `role` reste présent mais **n'entre jamais dans une décision d'autorisation**. Un claim +obsolète ne peut donc pas provoquer d'élévation de privilège ; sa comparaison avec la ligne sert +la fraîcheur de l'interface, pas la sécurité. + +Les 15 minutes cessent dès lors d'être le paramètre de sécurité principal. Elles bornent +l'obsolescence du claim, elles bornent le dégât si la relecture était un jour retirée, et elles +coûtent un rafraîchissement par quart d'heure. C'est une marge, pas une garantie. + +## Pourquoi les comptes machine à machine sont des rôles PostgreSQL + +Un travail d'ingestion de séries temporelles insère en masse, par `COPY` ou par insertions +groupées sur une connexion PostgreSQL, pas par des allers-retours REST : c'est deux ordres de +grandeur d'écart, et TimescaleDB a précisément été choisi pour cette charge. + +Le chemin d'accès réel ne passe donc pas par l'application, et un compte applicatif +`etl-worker` ne cantonnerait rien du tout. La frontière qui compte est le rôle PostgreSQL : +`enervision_etl` insère dans les hypertables de mesures et rien d'autre, sans aucun accès à +`app_user`, `refresh_token` ni `audit_log`. + +Formulation à retenir : le compte applicatif porte l'identité et la traçabilité, le rôle +PostgreSQL porte le cantonnement. Le premier sans le second serait du théâtre. + +**Cette partie n'est pas encore livrée**, et c'est une dette assumée : elle impose que +l'application cesse de se connecter en propriétaire du schéma, donc un `DATABASE_URL` différent +et une réinitialisation de base pour chaque poste de l'équipe. À ouvrir en ticket avec l'équipe +chargée de l'ETL. + +## Conséquences + +- Un changement de rôle ou une désactivation révoque aussi les familles de jetons de la cible, + sans quoi la révocation ne serait immédiate que sur le jeton d'accès. +- `credentials_changed_at` est comparé à la seconde entière, parce que `iat` est une date JWT et + n'a pas de précision inférieure. Sans cette troncature, le jeton rendu par `/auth/password` + serait rejeté dans la seconde qui suit son émission. +- Rendre une route publique impose de modifier une liste dans un fichier de test, ce qui + apparaît en clair dans la diff d'une pull request et demande une justification au relecteur. + Le garde-fou est social autant que technique. +- Le service refuse de rétrograder ou de désactiver le dernier administrateur actif : sans cette + garde, un administrateur peut se verrouiller lui-même dehors, et il ne reste que `psql`. + +## Alternatives écartées + +- **Matrice de permissions explicites** (`measure.read`, `user.create`…) : c'est la bonne réponse + à partir d'une dizaine de rôles. Ici, trois rôles totalement ordonnés se lisent en une ligne. + **Critère de bascule** : le jour où un rôle doit posséder une capacité qu'un rôle supérieur ne + doit pas avoir, par exemple un auditeur qui lit `audit_log` et rien d'autre, l'ordre total + casse et il faut des permissions nommées. +- **Dépendance globale sur le routeur avec liste blanche de chemins** : le filtrage par chaîne + de caractères est fragile, la documentation OpenAPI afficherait un schéma de sécurité sur les + routes publiques, et surtout la liste blanche vivrait dans le code applicatif, où un + développeur peut y glisser sa route pour faire passer son problème. +- **Portée par site** : c'est la limite connue de cette conception. Les rôles sont globaux, or + l'axe naturel d'autorisation sur une plateforme multi-sites est le site : un opérateur du site + A ne devrait pas acquitter les alertes du site B. En l'état, le risque BOLA reste ouvert. Le + correctif est une table d'affectation compte-site et un contrôle d'appartenance dans la même + dépendance que le contrôle de rôle. +- **Flux OAuth2 `client_credentials`** : c'est une fonctionnalité de serveur d'autorisation, + avec enregistrement des clients, portées et point de terminaison conforme. Des jours de + travail pour zéro consommateur HTTP actuel. Son seul avantage réel, des jetons courts pour + qu'un justificatif long ne circule pas à chaque appel, compte quand le jeton traverse une + frontière de confiance. Ici il n'en traverse aucune. diff --git a/docs/adr/0004-journal-d-audit-en-ajout-seul.md b/docs/adr/0004-journal-d-audit-en-ajout-seul.md new file mode 100644 index 0000000..cfb6a95 --- /dev/null +++ b/docs/adr/0004-journal-d-audit-en-ajout-seul.md @@ -0,0 +1,104 @@ +# 0004 - Journal d'audit en ajout seul, garanti par PostgreSQL + +- Statut : accepté +- Date : 2026-09-15 + +## Contexte + +Le dossier EC01 annonce une table `audit_log` « en ajout seul pour toute action +d'administration ». Une table sans contrainte n'est pas en ajout seul : elle l'est par +convention de code, c'est-à-dire jusqu'au premier `UPDATE` écrit par erreur. + +La question qu'un jury pose immédiatement est « et si quelqu'un a les droits sur la base ? ». +Elle mérite une réponse honnête plutôt qu'une parade. + +## Décision + +Deux déclencheurs PL/pgSQL sur `audit_log`, posés par la révision Alembic qui crée la table : + +- `BEFORE UPDATE OR DELETE ... FOR EACH ROW` +- `BEFORE TRUNCATE ... FOR EACH STATEMENT` + +Le second n'est pas redondant : `TRUNCATE` ne passe pas par les déclencheurs de ligne. Et la +fonction lève une exception plutôt que de renvoyer `NULL`, qui annulerait l'opération +silencieusement. + +`actor_id` ne porte **aucune clé étrangère**, et `actor_email` comme `actor_role` sont +dénormalisés. + +Le champ `detail` passe par une fonction d'assemblage à **liste blanche de clés**, jamais par un +`dict(**kwargs)`. + +## Pourquoi pas de clé étrangère sur l'acteur + +Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur d'ajout seul +refuserait : la suppression d'un compte échouerait. Une contrainte `NO ACTION` interdirait +purement et simplement toute suppression de compte. + +Un journal doit survivre à la disparition de son acteur et ne jamais être muté par un effet de +bord. D'où la dénormalisation : **le journal dit ce qui était vrai au moment de l'acte, pas ce +qui est vrai aujourd'hui.** + +## Ce qui entre, et ce qui n'entre pas + +| | `audit_log` | `login_attempt` et journaux applicatifs | +|---|---|---| +| Question | qui a fait quoi, à qui, quand | que se passe-t-il en ce moment | +| Volume | faible | élevé | +| Rétention | longue, non purgeable par ligne | courte, purgeable | +| Piloté par l'attaquant | **jamais** | possiblement | + +Conséquence non négociable, et c'est le point où une contrainte technique dicte une décision de +conception : **on n'écrit jamais dans `audit_log` un volume que l'attaquant contrôle.** Une +force brute y inscrirait des millions de lignes indestructibles. Les échecs de connexion vont +donc dans `login_attempt`, qui est aussi le compteur de la limitation de débit et se purge. + +La seule exception est `auth.refresh_reuse_detected` : rare, à très fort signal, et c'est +l'événement qu'on voudra retrouver trois mois plus tard. + +Corollaire : `audit_log` n'est **pas** une hypertable. Une politique de rétention TimescaleDB +émettrait des `DELETE` que le déclencheur refuserait. Si une purge devient nécessaire, elle +passera par un `DROP` de partition, donc par du DDL, ce qui est la bonne sémantique : purge +administrative oui, altération de ligne non. + +## Ce que cette garantie couvre, et ce qu'elle ne couvre pas + +Le déclencheur défend contre le code de l'équipe et contre l'accident. Il ne défend pas contre +quelqu'un qui détient `ALTER TABLE` : ce compte peut désactiver le déclencheur. + +La réponse honnête à « et si quelqu'un a les droits sur la base ? » est donc : alors l'audit +local ne vaut plus rien, et c'est vrai de tout journal co-localisé avec ce qu'il journalise. Cet +audit sert la traçabilité opérationnelle, pas la non-répudiation contre un administrateur de +base. Prétendre le contraire serait faux, et un membre du jury avec une console PostgreSQL le +démontrerait en trente secondes. + +Le palier suivant est double, et il est assumé comme dette : + +1. **Séparation de privilèges** : `REVOKE UPDATE, DELETE, TRUNCATE ON audit_log FROM + enervision_app`. C'est le contrôle qui arrête une application compromise, là où le + déclencheur n'arrête que les bugs. Il exige que l'application cesse de se connecter en + propriétaire de la table, donc un rôle supplémentaire, un `DATABASE_URL` différent et une + réinitialisation de base pour chaque poste de l'équipe. Reporté pour cette raison. +2. **Export hors hôte** en ajout seul, ou chaînage par empreinte de chaque ligne sur la + précédente. C'est le seuil au-delà duquel on peut parler de non-répudiation. + +## Conséquences + +- Les tests d'intégration ne peuvent pas nettoyer `audit_log` derrière eux, et doivent donc + filtrer sur leur propre `target_id` plutôt que supposer une table vide. +- Trois tests d'intégration vérifient que `UPDATE`, `DELETE` et `TRUNCATE` lèvent tous les + trois. Ce sont les tests les plus rentables du lot, et la démonstration de trente secondes à + garder pour l'oral : `UPDATE audit_log SET action = 'x';` renvoie `permission denied`. +- L'adresse IP est une donnée personnelle. `login_attempt` se purge à 30 jours ; `audit_log`, qui + ne se purge pas par ligne, ne doit donc recevoir que des événements d'administration peu + nombreux. + +## Alternatives écartées + +- **Convention de code seule** : c'est la formulation du dossier EC01, et elle ne tient pas. Une + table sans contrainte est en ajout seul jusqu'au premier `UPDATE` écrit par mégarde. +- **Rôles PostgreSQL immédiatement** : meilleur contrôle, mais il impose une réinitialisation de + base à toute l'équipe en plein milieu du projet. Le déclencheur d'abord, les privilèges + ensuite. +- **`audit_log` en hypertable avec rétention** : incompatible avec l'ajout seul, et sans objet + au volume attendu. diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 0794bfb..15a54c0 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -109,24 +109,52 @@ consolidée. ### En place +- **Authentification et autorisation.** JWT d'accès de 15 minutes, jeton de rafraîchissement + opaque en cookie `HttpOnly` avec rotation et détection de réutilisation, mots de passe en + Argon2id, RBAC à trois rôles. Détail dans [20-backend.md](20-backend.md), décisions dans les + [ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) et + [0003](../adr/0003-autorisation-rbac-a-trois-roles.md). +- **Interdire par défaut.** Toute route exige un jeton, sauf quatre exceptions listées dans un + fichier de test qui interroge réellement chaque route sans identifiant. +- **Révocation immédiate.** Le compte est relu en base à chaque requête : une désactivation ou un + changement de rôle prend effet à la requête suivante, pas au bout de 15 minutes. +- **Limitation de débit à fenêtre glissante** sur trois clés, évaluée avant le hachage. Pas de + verrouillage de compte, qui serait un déni de service trivial. +- **Journal d'audit en ajout seul**, garanti par deux déclencheurs PostgreSQL + ([ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md)). - **Les secrets n'ont pas de valeur par défaut.** `APP_SECRET_KEY` et `DATABASE_URL` sont requis - sans repli : l'application refuse de démarrer si l'un manque, plutôt que de tourner avec une - valeur de démonstration. `.env` reste hors dépôt, `.env.example` est versionné. -- **CORS conditionnel** : le middleware n'est ajouté que si `APP_CORS_ORIGINS` est renseigné. -- **Documentation interactive fermée en production** : `/docs`, `/redoc` et `/openapi.json` sont - désactivés dès que `APP_ENV=prod`. + sans repli, et la configuration refuse de démarrer sur cinq erreurs silencieuses : secret trop + court ou laissé à sa valeur d'exemple, `debug` en production, joker CORS, origines vides hors + local, cookie `SameSite=None` sans `Secure`. +- **CORS explicite** : origines listées, méthodes et en-têtes énumérés, jamais de joker. +- **En-têtes de sécurité** posés par l'application (`nosniff`, `DENY`, `no-referrer`) et + `Cache-Control: no-store` sur les routes d'authentification. +- **Caviardage des journaux** : jetons, empreintes Argon2, mots de passe et cookies sont + expurgés avant écriture. +- **Documentation interactive fermée** en préproduction et en production, `/metrics` derrière un + jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB. +- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture. - **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`. - **Côté infrastructure** : la clé SSH est marquée `sensitive`, le kubeconfig reste en `600/root` sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les `.example`. -### Absent +### Absent, et assumé -- **Aucune authentification ni autorisation.** Les deux endpoints exposés sont publics. Rien - n'est encore décidé sur ce point. -- Pas de TLS, pas de limitation de débit, pas de journalisation des accès, pas de rotation des - secrets. -- Aucune analyse de dépendances ni de conteneur, faute de CI. +- **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage. C'est la vraie + frontière pour ces deux consommateurs, qui écrivent en base et non par HTTP. Reporté parce que + cela impose une réinitialisation de base à toute l'équipe. Voir l'ADR 0003. +- **`REVOKE` sur `audit_log`** : les déclencheurs arrêtent les accidents, les privilèges + arrêteraient une application compromise. Même raison de report. +- **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut + agir sur le site B. C'est la limite connue du modèle. +- **TLS, HSTS et CSP** : ils appartiennent au terminateur TLS, qui n'existe pas encore. +- **Limitation de débit au frontal** : celle de l'application protège les identifiants, pas + l'infrastructure. +- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD. +- **Le fichier `environment.ts` de production** pointe encore sur `http://localhost:8000` en HTTP + simple : dans cet état, le cookie `Secure` ne sera pas posé. Voir + [31-contrat-authentification.md](31-contrat-authentification.md). ## Décisions structurantes diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index d975da6..8688a6a 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -8,23 +8,23 @@ La doctrine est posée dans [`apps/backend/README.md`](../../apps/backend/README [`TESTING.md`](../../apps/backend/TESTING.md) : `endpoints` appelle `services`, qui appelle `repositories`, qui seuls touchent les `models`. Le sens de dépendance ne s'inverse jamais. -Dans les faits, trois de ces couches sont des dossiers vides. +Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
2 routes"] - sc["schemas
2 modèles Pydantic"] - sv["services
vide"] - rp["repositories
vide"] - md["models
vide"] + ep["endpoints
health, auth, users"] + sc["schemas
Pydantic"] + sv["services
AuthService, UserService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log"] + md["models
4 tables"] db[("PostgreSQL")] ep --> sc - ep -.-> sv - sv -.-> rp - rp -.-> md - ep -->|"SQL brut, état actuel"| db - rp -.-> db + ep --> sv + sv --> rp + rp --> md + ep -->|"SQL brut, sonde seulement"| db + rp --> db ``` Le trait plein de `endpoints` vers la base n'est pas une erreur de dessin : `/health/ready` @@ -32,9 +32,13 @@ exécute aujourd'hui son `SELECT` directement, sans repository. C'est acceptable d'infrastructure, qui vérifie la base elle-même et non une donnée métier. Ce raccourci ne doit pas servir de modèle au premier endpoint métier. -`app/models/__init__.py` ne contient qu'un avertissement, qui mérite d'être connu avant la -première migration : tout modèle absent de ce module reste invisible d'un -`alembic revision --autogenerate`, qui produirait alors un `drop` de sa table. +`app/models/__init__.py` porte un avertissement qui reste valable à chaque nouveau modèle : +tout modèle absent de ce module est invisible d'un `alembic revision --autogenerate`, qui +produirait alors un `drop` de sa table. L'export va dans le même commit que le modèle. + +`AuthService` et `UserService` ne connaissent ni `AsyncSession` ni `Request` : ils reçoivent +leurs dépôts et une `Transaction` réduite à `commit()`. C'est ce qui les rend testables sans +base, avec des doubles écrits à la main. ## Démarrage @@ -81,14 +85,41 @@ démarre ne prouve rien sur la base, la première connexion réelle a lieu au pr | `APP_API_PREFIX` | `/api/v1` | | | `APP_DATABASE_POOL_SIZE` | `5` | | | `APP_DATABASE_MAX_OVERFLOW` | `10` | | +| `APP_JWT_ISSUER` | `enervision-api` | Claim `iss`, vérifié au décodage | +| `APP_JWT_AUDIENCE` | `enervision-web` | Claim `aud`, vérifié au décodage | +| `APP_ACCESS_TOKEN_TTL_SECONDS` | `900` | Durée du jeton d'accès | +| `APP_REFRESH_TOKEN_TTL_SECONDS` | `604800` | Durée absolue d'une session, héritée à chaque rotation | +| `APP_REFRESH_COOKIE_NAME` | `ev_refresh` | Préfixé `__Secure-` dès que le cookie est `Secure` | +| `APP_COOKIE_PATH` | `/api/v1/auth` | Le cookie ne part que sur ces routes | +| `APP_COOKIE_SAMESITE` | `strict` | | +| `APP_COOKIE_SECURE` | déduit | Vrai hors `local` si non renseigné | +| `APP_ARGON2_TIME_COST` | `2` | | +| `APP_ARGON2_MEMORY_COST_KIB` | `19456` | Profil OWASP, environ 17 ms mesurés | +| `APP_ARGON2_PARALLELISM` | `1` | | +| `APP_ARGON2_MAX_CONCURRENCY` | `4` | Plafonne le pic mémoire du hachage | +| `APP_LOGIN_WINDOW_SECONDS` | `900` | Fenêtre glissante de la limitation | +| `APP_LOGIN_MAX_FAILURES_PER_IDENTIFIER_AND_IP` | `5` | Remplace le verrouillage de compte | +| `APP_LOGIN_MAX_FAILURES_PER_IP` | `20` | Arrête le balayage | +| `APP_LOGIN_MAX_FAILURES_PER_IDENTIFIER` | `50` | Signature d'une attaque distribuée | +| `APP_TRUST_PROXY_HEADERS` | `false` | À vrai derrière un proxy, sinon le compteur par IP devient global | +| `APP_EXPOSE_API_DOCS` | déduit | Faux en `staging` et `prod` si non renseigné | +| `APP_METRICS_TOKEN` | absent | Si présent, `/metrics` exige `Authorization: Bearer` | -Deux pièges : +Cinq gardes refusent de démarrer plutôt que de laisser passer une erreur silencieuse : +secret de moins de 32 caractères ou laissé à sa valeur d'exemple, `debug` en `staging` ou +`prod`, joker dans `APP_CORS_ORIGINS`, liste d'origines vide hors `local`, et cookie +`SameSite=None` sans `Secure`. + +Trois pièges : - **`DATABASE_URL` ne prend pas le préfixe `APP_`.** C'est le seul réglage dans ce cas, par `validation_alias`, pour rester compatible avec la convention d'Alembic et des hébergeurs. - **`APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par défaut.** L'application refuse de démarrer si l'un manque. C'est délibéré : mieux vaut un échec au démarrage qu'un service qui tourne avec un secret de démonstration. +- **Une `Settings` passée à `create_app()` pilote aussi les dépendances.** La factory installe + une surcharge de `get_settings` ; sans elle, un test « en production » testerait la + configuration du poste. Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docker-compose.yml`, `apps/backend/.env` alimente l'API lancée sur le poste. @@ -99,16 +130,36 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke |---|---|---|---| | GET | `/api/v1/health/live` | oui | Le processus répond. Ne touche pas la base | | GET | `/api/v1/health/ready` | oui | La base répond **et** l'extension TimescaleDB est chargée | -| GET | `/metrics` | non | Format Prometheus, exposé par l'instrumentator | -| GET | `/docs`, `/redoc`, `/openapi.json` | non | Désactivés quand `APP_ENV=prod` | +| POST | `/api/v1/auth/login` | oui | Ouvre une session. Publique | +| POST | `/api/v1/auth/refresh` | oui | Fait tourner la session. Cookie seulement | +| POST | `/api/v1/auth/logout` | oui | Ferme la session courante. Idempotente | +| POST | `/api/v1/auth/logout-all` | oui | Ferme toutes les sessions du compte | +| POST | `/api/v1/auth/password` | oui | Change son propre mot de passe | +| GET | `/api/v1/auth/me` | oui | Décrit le compte connecté | +| GET | `/api/v1/users` | oui | Liste les comptes. `admin` | +| POST | `/api/v1/users` | oui | Crée un compte, rend un mot de passe provisoire. `admin` | +| PATCH | `/api/v1/users/{id}` | oui | Change le rôle ou l'activation. `admin` | +| POST | `/api/v1/users/{id}/password-reset` | oui | Réinitialise et ferme les sessions. `admin` | +| GET | `/metrics` | non | Format Prometheus. Jeton requis si `APP_METRICS_TOKEN` est posé | +| GET | `/docs`, `/redoc`, `/openapi.json` | non | Fermés en `staging` et en `prod` | -Aucune route métier n'existe à ce jour. +**Quatre routes seulement sont publiques** : les deux sondes, `/auth/login` et `/auth/logout`. +`tests/api/test_route_protection.py` interroge réellement chaque autre route sans identifiant et +échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose +donc de modifier la liste dans ce fichier de test. + +Aucune route métier n'existe à ce jour. Le contrat détaillé pour le frontend est dans +[31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un bootstrap de base sauté ne se voit pas au démarrage de l'API, elle le rend visible. +Elle ne publie **pas** la version de l'extension, qui part dans le journal : une version exacte +de composant servie sans authentification est de la reconnaissance gratuite pour qui cherche +une CVE. + ```mermaid sequenceDiagram participant C as Client @@ -121,26 +172,54 @@ sequenceDiagram R->>D: SELECT extversion FROM pg_extension WHERE extname = 'timescaledb' alt base injoignable D--xR: SQLAlchemyError ou OSError - R-->>C: 503 Base de donnees injoignable + R-->>C: 503 Base de données injoignable else extension absente D-->>R: NULL R-->>C: 503 Extension TimescaleDB absente else D-->>R: version de l'extension - R-->>C: 200 status ready + R-->>C: 200 timescaledb loaded end ``` ## Sécurité -Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md). Côté backend : +Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les +[ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md), +[0003](../adr/0003-autorisation-rbac-a-trois-roles.md) et +[0004](../adr/0004-journal-d-audit-en-ajout-seul.md). Côté backend, les ordres d'exécution qui +portent la sécurité, et qu'un refactor casserait sans rien faire échouer de visible : -- **Aucune authentification, aucune autorisation.** Les deux routes sont publiques. Le premier - endpoint métier imposera de trancher ce point. -- Le CORS n'autorise que les origines listées, et n'existe pas si la liste est vide. -- `/docs`, `/redoc` et `/openapi.json` disparaissent en production. +1. **Les compteurs de limitation sont lus avant le hachage Argon2.** Dans l'autre ordre, chaque + requête rejetée coûterait quand même 17 ms de processeur et 19 Mio de mémoire, et la + protection deviendrait l'amplificateur de déni de service qu'elle doit empêcher. +2. **Un haché leurre est vérifié quand l'adresse est inconnue.** Sans lui, l'écart entre 2 ms et + 17 ms est un oracle d'existence de compte, mesurable à distance. +3. **La tentative échouée est validée en base avant que l'erreur ne soit levée.** `get_session()` + ne valide pas de lui-même : la preuve disparaîtrait avec la transaction. +4. **Un jeton de rafraîchissement déjà tourné révoque toute sa famille ; un jeton expiré ne + révoque rien.** La rotation ne protège de rien par elle-même, elle rend la réutilisation + détectable. + +Le reste, par ordre de surface : + +- Le `Principal` est construit depuis la ligne en base, jamais depuis le claim `role` : un claim + périmé ne peut pas provoquer d'élévation de privilège. +- `credentials_changed_at` est comparé à la seconde entière, parce que `iat` est une date JWT et + n'a pas de précision inférieure. +- Le CORS liste ses origines, ses méthodes et ses en-têtes. Il n'est pas monté si la liste est + vide, et la configuration refuse de démarrer dans ce cas hors `local`. +- La 422 renvoie le champ fautif et le type d'erreur, **jamais la valeur rejetée** : la réponse + par défaut de FastAPI contient `input`, donc le mot de passe sur `/auth/login`. +- La 500 renvoie un identifiant de corrélation, la trace reste côté serveur. +- Un filtre de caviardage expurge jetons, empreintes Argon2, mots de passe et cookies avant + écriture des journaux. C'est la troisième ligne de défense : la première est de ne rien passer + de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL. +- En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`, + `Referrer-Policy`, plus `Cache-Control: no-store` sur `/auth/*`. HSTS et CSP appartiennent au + terminateur TLS, que l'application ne connaît pas. - Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`. -- Ni limitation de débit, ni journalisation des accès, ni en-têtes de sécurité. +- Ni limitation de débit au frontal, ni TLS, ni journalisation des accès applicative. ## Observabilité @@ -151,13 +230,24 @@ Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md). Côté ba ## Tests Conventions, gabarits et arborescence : [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md). -Deux points structurants y sont fixés : les doubles passent par `app.dependency_overrides` et -jamais par `unittest.mock`, et les tests qui touchent la vraie base portent le marqueur -`integration`, exclu par défaut. + +Trois fichiers méritent d'être connus avant de toucher à l'authentification : + +- `tests/api/test_route_protection.py` : le garde-fou de l'autorisation, décrit plus haut. +- `tests/services/test_auth.py` : le faux hacheur y porte un compteur d'appels, ce qui permet les + deux assertions qui prouvent le design, à savoir un appel quand l'adresse est inconnue et zéro + appel quand la limite est atteinte. +- `tests/api/test_parcours_authentification.py` : six parcours contre la vraie base, sous le + marqueur `integration`. C'est là que se démontrent l'atomicité de la rotation, la mort de la + famille au rejeu et la révocation immédiate. ## Questions ouvertes -- **Authentification et autorisation** : quel mécanisme, quelle granularité. +- **Portée par site dans l'autorisation** : les rôles sont globaux, un opérateur du site A peut + agir sur le site B. C'est la limite connue du modèle, et le risque BOLA du top 10 API. +- **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage, plus le `REVOKE` sur + `audit_log`. Dette assumée, décrite dans les ADR 0003 et 0004. - **Pagination et fenêtrage** des lectures de séries temporelles, qui conditionnent la forme des - endpoints métier. + endpoints métier. Sans plafond dur, une requête sur dix ans d'historique suffit à faire tomber + l'API. - **Politique de versionnement de l'API** au-delà du préfixe `/api/v1`. diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md new file mode 100644 index 0000000..f02fd1b --- /dev/null +++ b/docs/architecture/31-contrat-authentification.md @@ -0,0 +1,144 @@ +# Contrat d'authentification, côté frontend + +Ce que le frontend doit savoir pour coder la connexion, et rien de plus. Le raisonnement est +dans l'[ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md). + +Statut : `Fait` côté backend, `Cible` côté Angular. + +## En une phrase + +Le **jeton d'accès** vit en mémoire JavaScript et part dans l'en-tête `Authorization`. Le +**jeton de rafraîchissement** est un cookie `HttpOnly` que le code ne voit jamais et n'a pas à +gérer : il suffit d'envoyer les requêtes avec `withCredentials`. + +## Endpoints + +| Méthode | Chemin | Authentification | Réponse | +|---|---|---|---| +| POST | `/api/v1/auth/login` | aucune | `200` `TokenResponse` | +| POST | `/api/v1/auth/refresh` | cookie | `200` `TokenResponse` | +| POST | `/api/v1/auth/logout` | cookie | `204` | +| POST | `/api/v1/auth/logout-all` | jeton d'accès | `204` | +| POST | `/api/v1/auth/password` | jeton d'accès | `200` `TokenResponse` | +| GET | `/api/v1/auth/me` | jeton d'accès | `200` `PrincipalResponse` | +| GET | `/api/v1/users` | jeton d'accès, `admin` | `200` `UserResponse[]` | +| POST | `/api/v1/users` | jeton d'accès, `admin` | `201` `TemporaryPasswordResponse` | +| PATCH | `/api/v1/users/{id}` | jeton d'accès, `admin` | `200` `UserResponse` | +| POST | `/api/v1/users/{id}/password-reset` | jeton d'accès, `admin` | `200` `TemporaryPasswordResponse` | + +Le schéma exact est dans `/docs` (Swagger), servi en local et en développement. + +## Charges utiles + +```jsonc +// POST /auth/login +{ "email": "operateur@enervision.fr", "password": "..." } + +// TokenResponse, rendu par login, refresh et password +{ + "access_token": "eyJ...", + "token_type": "bearer", + "expires_in": 900, + "principal": { + "id": "3f2a...", + "email": "operateur@enervision.fr", + "role": "lecteur | operateur | admin", + "kind": "human", + "must_change_password": false + } +} + +// POST /auth/password +{ "current_password": "...", "new_password": "..." } // 12 à 128 caractères +``` + +Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la réponse. + +## Codes d'erreur à traiter + +| Code | Quand | Ce que fait le frontend | +|---|---|---| +| `401` sur `/auth/login` | identifiants faux, compte désactivé, compte inconnu | afficher le message générique tel quel, ne rien déduire de plus | +| `429` sur `/auth/login` | trop de tentatives | afficher l'attente, l'en-tête `Retry-After` donne les secondes | +| `401` avec `WWW-Authenticate: ... error="expired"` | jeton d'accès périmé | **rafraîchir**, puis rejouer la requête | +| `401` avec `error="token_stale"` | rôle changé ou compte désactivé pendant la session | **rafraîchir** ; si le rafraîchissement échoue, déconnecter | +| `401` avec `error="invalid_token"` | jeton illisible ou compte disparu | déconnecter | +| `401` sur `/auth/refresh` | session révoquée, expirée ou rejouée | **déconnecter** et renvoyer vers la page de connexion | +| `403` avec `detail: "password_change_required"` | mot de passe provisoire | rediriger vers l'écran de changement de mot de passe | +| `403` avec `detail: "Droits insuffisants"` | rôle trop bas | masquer ou griser l'action, ne pas déconnecter | +| `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée | + +## Les quatre règles qui comptent + +**1. Le jeton d'accès ne se persiste jamais.** Ni `localStorage`, ni `sessionStorage`, ni +cookie : un signal dans un service racine. Un rechargement de page le perd, c'est voulu. + +**2. Au démarrage de l'application, appeler `/auth/refresh`.** C'est ce qui restaure la session +après un rechargement, via `provideAppInitializer`. Un `401` y est normal : il signifie +simplement qu'il n'y a pas de session, on affiche la page de connexion. + +**3. Un seul rafraîchissement en vol à la fois.** C'est une exigence, pas une optimisation. +Cinq requêtes parallèles qui prennent cinq fois `401` déclencheraient cinq rotations +concurrentes ; le serveur n'en accepte qu'une et considère les autres comme un rejeu, ce qui +**révoque toute la session**. L'utilisateur serait déconnecté à chaque chargement de page. + +```ts +// Dans l'intercepteur : une seule rotation partagée par tous les appelants. +private rotation$?: Observable; + +private rafraichir(): Observable { + this.rotation$ ??= this.http.post('/api/v1/auth/refresh', {}, { withCredentials: true }) + .pipe(finalize(() => (this.rotation$ = undefined)), shareReplay(1)); + return this.rotation$; +} +``` + +**4. Toutes les requêtes vers `/auth/*` portent `withCredentials: true`.** Sans quoi le cookie +n'est pas envoyé et le rafraîchissement échoue toujours. + +## Ce qu'il faut savoir sur le cookie + +- Nom `ev_refresh` en local, `__Secure-ev_refresh` ailleurs. Le code ne le lit jamais. +- `HttpOnly`, `SameSite=Strict`, `Path=/api/v1/auth`. Il n'est donc envoyé que sur ces routes. +- `Secure` dès que l'environnement n'est pas `local`, donc **HTTPS obligatoire hors poste de + développement**. +- `HttpOnly` empêche de voler le cookie, pas de s'en servir : une XSS peut appeler + `/auth/refresh` depuis l'origine de la victime. La vraie défense contre ce cas reste de ne pas + avoir de XSS. + +## Dev et production, le point à ne pas rater + +En développement, `proxy.conf.json` fait passer `/api` par `localhost:4200`, donc tout est +**même origine** et le cookie marche sans rien configurer. + +En production, `src/environments/environment.ts` contient encore le gabarit +`http://localhost:8000/api/v1`, en HTTP simple et sur une autre origine. **Dans cet état, aucun +cookie `Secure` ne sera posé et l'authentification ne fonctionnera pas.** + +Deux corrections, à faire avant la démonstration : + +1. passer `apiUrl` à `/api/v1` et servir le SPA et l'API sous la même origine, via un + `location /api` dans le `nginx.conf` du conteneur frontend ou via l'ingress ; +2. servir en HTTPS. + +Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin +réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que le proxy masque. + +## Origines autorisées + +Le backend ne monte le middleware CORS que si `APP_CORS_ORIGINS` est renseigné, et refuse de +démarrer hors `local` si la liste est vide. Les routes portant le cookie vérifient en plus +l'en-tête `Origin` : une origine absente de la liste reçoit un `403`. + +Méthodes autorisées : `GET`, `POST`, `PATCH`, `PUT`, `DELETE`, `OPTIONS`. +En-têtes autorisés : `Authorization`, `Content-Type`. En-tête exposé : `Retry-After`. + +## Premier compte + +Créé en ligne de commande côté serveur (`make bootstrap-admin EMAIL=...`), avec +`must_change_password` à vrai. La première connexion renvoie donc `403 +password_change_required` sur toute route métier, et seuls `/auth/me` et `/auth/password` +répondent. L'écran de changement de mot de passe doit exister avant la démonstration. + +Idem pour tout compte créé par un administrateur : le mot de passe provisoire est affiché **une +seule fois** dans la réponse, il n'est plus jamais récupérable. diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 2d53844..86d65d1 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -35,8 +35,8 @@ Statut : `Fait`. - `db/init/100-extensions.sql` crée l'extension `timescaledb`. - `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par `apps/backend/tests/conftest.py`. -- Une révision Alembic, `5353c0e4f094`, qui **ne crée aucune table**. Elle établit - `alembic_version` et refuse de s'appliquer si l'extension manque : +- Quatre révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle + établit `alembic_version` et refuse de s'appliquer si l'extension manque : ```sql IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN @@ -47,6 +47,9 @@ END IF; Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap sauté ne se voit pas au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés. +Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`, +puis `login_attempt` et `audit_log`, puis `refresh_token`. + ## Cycle de vie d'une mesure Statut : `Cible`. Aucun de ces maillons n'existe. @@ -65,7 +68,72 @@ flowchart LR Les lectures de l'API et de Grafana visent l'agrégat continu, pas la table brute : c'est tout l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront. -## Modèle +## Tables d'authentification + +Statut : `Fait`. Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les +hypertables ; elles vivent dans `apps/backend/alembic/`, qui porte le schéma exposé par l'API. + +```mermaid +erDiagram + APP_USER ||--o{ REFRESH_TOKEN : ouvre + APP_USER { + uuid id PK + string email UK + text password_hash + text role + text kind + bool is_active + bool must_change_password + timestamptz credentials_changed_at + } + REFRESH_TOKEN { + uuid id PK + uuid family_id + uuid user_id FK + bytea token_hash UK + timestamptz expires_at + timestamptz rotated_at + timestamptz revoked_at + text revoked_reason + uuid replaced_by + } + LOGIN_ATTEMPT { + bigint id PK + timestamptz occurred_at + string email_tried + inet client_ip + text outcome + } + AUDIT_LOG { + bigint id PK + timestamptz occurred_at + uuid actor_id + text actor_email + text action + jsonb detail + } +``` + +Quatre choix de modélisation portent une intention et se défendent seuls : + +- **`app_user` et non `user`** : `user` est un mot réservé PostgreSQL, raccourci de + `CURRENT_USER`. Le nom rappelle en prime qu'il s'agit d'un compte applicatif, par opposition + au rôle PostgreSQL qui portera le cantonnement de l'ETL. +- **`credentials_changed_at`, une seule colonne**, couvre le changement de mot de passe, le + changement de rôle et la désactivation. Un compteur de version ne dirait rien à un humain qui + lit un audit. +- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation. S'il + glissait, la promesse de sept jours serait fictive et une session active ne finirait jamais. +- **`audit_log.actor_id` n'a aucune clé étrangère**, et `actor_email` comme `actor_role` sont + dénormalisés. Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur + d'ajout seul refuserait. Voir l'[ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md). + +`audit_log` porte deux déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. Elle n'est +donc **pas** une hypertable : une politique de rétention émettrait des `DELETE` qu'ils +refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est +piloté par l'attaquant. + +## Modèle métier Statut : `Cible`. Les entités ci-dessous sont des **candidates**, à valider en J2. Elles s'appuient sur les gabarits de [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md), qui diff --git a/docs/architecture/README.md b/docs/architecture/README.md index a23be40..c6b91f0 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -12,12 +12,17 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard. | [10-infra.md](10-infra.md) | Poste de développement, cible k3s, décisions figées, ports et noms | | [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration | | [30-frontend.md](30-frontend.md) | Angular, arborescence cible, flux HTTP | +| [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion | | [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle | -L'observabilité, la sécurité et la CI/CD n'ont pas de document propre : ce sont des sections des -cinq ci-dessus, tant que `monitoring/`, `.github/workflows/` et `etl/airflow/` ne contiennent que -des `.gitkeep`. Elles en sortiront le jour où elles auront de la matière. Un fichier vide de plus -n'aide personne. +L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents +ci-dessus, tant que `monitoring/` et `etl/airflow/` ne contiennent que des `.gitkeep`. Elles en +sortiront le jour où elles auront de la matière. Un fichier vide de plus n'aide personne. + +La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans +[00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la +traçabilité OWASP dans [owasp-traceabilite.md](owasp-traceabilite.md), et les décisions dans les +ADR 0002 à 0004. ## Conventions diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md new file mode 100644 index 0000000..ada1a45 --- /dev/null +++ b/docs/architecture/owasp-traceabilite.md @@ -0,0 +1,70 @@ +# Traçabilité OWASP + +Ce document remplace la revendication « couverture OWASP Top 10 et OWASP API Security Top 10 » +de la NFR4 du dossier EC01. Cette formulation est indéfendable telle quelle : vingt items, non +vérifiables en deux semaines, et « montrez-moi votre couverture de A04 Insecure Design » n'a pas +de réponse honnête. + +Ce qui est défendable, c'est une ligne par contrôle réellement implémenté, l'item qu'il adresse, +et une section qui dit ce qui n'est pas couvert et pourquoi. + +Statut : `Fait` pour le périmètre authentification et autorisation. Les endpoints métier +n'existent pas encore, donc plusieurs lignes resteront à compléter. + +## Contrôles en place + +| Contrôle | Où | Item adressé | +|---|---|---| +| Interdire par défaut, liste blanche de routes publiques vérifiée par un test qui appelle réellement chaque route | `tests/api/test_route_protection.py` | API5 Broken Function Level Authorization, A01 Broken Access Control | +| RBAC à trois rôles ordonnés, décision prise sur la ligne en base et jamais sur le claim | `app/api/deps.py` | A01, API5 | +| Révocation immédiate : compte relu à chaque requête, `credentials_changed_at` invalide les jetons antérieurs | `app/api/deps.py`, `app/repositories/user.py` | A01, API2 Broken Authentication | +| Argon2id m=19456 t=2 p=1, re-hachage passif quand les paramètres changent | `app/core/hashing.py` | A02 Cryptographic Failures, A07 Identification and Authentication Failures | +| Message et temps de réponse identiques quelle que soit la cause de l'échec, haché leurre sur adresse inconnue | `app/services/auth.py` | A07, API2 | +| Limitation de débit à fenêtre glissante sur trois clés, évaluée avant le hachage | `app/services/auth.py`, `app/repositories/login_attempt.py` | A07, API4 Unrestricted Resource Consumption | +| Absence de verrouillage de compte, qui serait un déni de service | ADR 0002 | API4 | +| Jeton de rafraîchissement opaque, haché en base, rotation avec détection de réutilisation | `app/services/auth.py`, `app/repositories/refresh_token.py` | A07, API2 | +| Séparation structurelle accès / rafraîchissement, impossible à confondre | ADR 0002 | API2 | +| Algorithme épinglé, `aud`, `iss` et `typ` vérifiés, `alg: none` rejeté | `app/core/security.py` | A02, API2 | +| Cookie `HttpOnly`, `Secure`, `SameSite=Strict`, `Path` restreint, suppression symétrique | `app/core/cookies.py` | A05 Security Misconfiguration | +| Vérification d'`Origin` sur les trois routes portant le cookie | `app/api/deps.py` | A01 | +| Schémas de lecture et d'écriture séparés, aucun modèle ORM en réponse | `app/schemas/user.py` | API3 Broken Object Property Level Authorization | +| Validation stricte Pydantic en entrée, mot de passe borné à 128 caractères | `app/schemas/auth.py` | A03 Injection, API4 | +| Requêtes paramétrées par SQLAlchemy, aucune concaténation SQL | `app/repositories/` | A03 | +| Réponse 422 qui ne renvoie jamais la valeur rejetée | `app/api/errors.py` | A09 Security Logging and Monitoring Failures | +| Réponse 500 générique avec identifiant de corrélation, trace côté serveur seulement | `app/api/errors.py` | A05 | +| Journal d'audit en ajout seul garanti par déclencheurs, liste blanche des clés de détail | ADR 0004, `app/repositories/audit_log.py` | A09 | +| Caviardage des jetons, empreintes, mots de passe et cookies dans les journaux | `app/core/logging.py` | A09, A02 | +| Cinq gardes de configuration qui refusent le démarrage plutôt que de dégrader silencieusement | `app/core/config.py` | A05 | +| Documentation interactive fermée hors développement, `/metrics` derrière un jeton, sonde qui ne publie plus de version | `app/main.py`, `app/api/security.py` | A05 | +| En-têtes `nosniff`, `DENY`, `no-referrer`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 | +| Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design | +| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 | +| CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components | + +Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des +règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu'annonce l'EC01. + +## Non couvert, et pourquoi + +| Item | État | Raison | +|---|---|---| +| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site. Un opérateur du site A pourra agir sur le site B dès que les endpoints métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. | +| **API4, lectures de séries temporelles** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. | +| **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. | +| **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. | +| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. | +| **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. | +| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. | +| **Non-répudiation de l'audit** | **dette assumée** | Les déclencheurs arrêtent les accidents, pas un compte détenant `ALTER TABLE`. Voir ADR 0004. | + +## Ce qu'il faut répondre, et ne pas répondre + +Sur A04 Insecure Design, la réponse n'est pas une case cochée mais deux décisions concrètes : le +refus du verrouillage de compte, qui aurait été un déni de service, et le refus de laisser un +administrateur se verrouiller lui-même dehors. + +Sur l'audit, ne jamais prétendre que la table est inviolable : elle ne l'est pas contre un compte +qui a les droits sur la base, et c'est vrai de tout journal co-localisé avec ce qu'il journalise. + +Sur l'API Mock, ne jamais répondre « c'est un mock, ce n'est pas notre périmètre ». C'est +précisément le périmètre : c'est la frontière de confiance. From bfbd9ee2cc798f556dfb5d73f84693097c581a56 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 15:06:20 +0200 Subject: [PATCH 13/13] test(backend): couvre le changement de mot de passe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AuthService.change_password` n'avait aucun test unitaire, alors qu'il porte la promesse que l'appareil courant reste connecté pendant que tous les autres tombent. Deux cas : le nominal, où une seule session est rouverte après la révocation, et le refus quand le mot de passe actuel est faux, qui ne doit rien révoquer. --- apps/backend/tests/services/test_auth.py | 55 ++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 01697d8..9b8c42c 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -56,6 +56,7 @@ class FauxDepotComptes: self.compte = compte self.rehachages = 0 self.connexions_datees = 0 + self.mots_de_passe_changes = 0 async def get_by_email(self, email: str) -> FauxCompte | None: return self.compte @@ -66,6 +67,9 @@ class FauxDepotComptes: async def rehash_password(self, user_id: UUID, password_hash: str) -> None: self.rehachages += 1 + async def update_password(self, user_id: UUID, password_hash: str, **_: object) -> None: + self.mots_de_passe_changes += 1 + async def touch_last_login(self, user_id: UUID) -> None: self.connexions_datees += 1 @@ -438,3 +442,54 @@ def test_fingerprint_is_what_the_service_stores_not_the_secret_itself() -> None: empreinte = fingerprint_refresh(secret) assert secret.encode() not in empreinte + + +async def test_change_password_revokes_every_session_then_reopens_the_current_one() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + acteur = Principal( + id=compte.id, + email=compte.email, + role=Role.OPERATEUR, + kind=AccountKind.HUMAIN, + must_change_password=True, + ) + + session = await attirail.service.change_password( + principal=acteur, + current_password="l-ancien-mot-de-passe", + new_password="le-nouveau-mot-de-passe", + client_ip="203.0.113.10", + user_agent="pytest", + ) + + assert attirail.jetons.revocations_par_compte == [ + (compte.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value) + ] + assert len(attirail.jetons.crees) == 1, "l'appareil courant doit repartir avec une session" + assert session.refresh_secret + assert "password_changed" in attirail.audit.lignes[0][0] + + +async def test_change_password_refuses_a_wrong_current_password() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte, hacheur=FauxHacheur(accepte=False)) + acteur = Principal( + id=compte.id, + email=compte.email, + role=Role.OPERATEUR, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + with pytest.raises(InvalidCredentialsError): + await attirail.service.change_password( + principal=acteur, + current_password="mauvais", + new_password="le-nouveau-mot-de-passe", + client_ip=None, + user_agent=None, + ) + + assert attirail.jetons.revocations_par_compte == [] + assert attirail.jetons.crees == []