From a8f59e6e76fca98de7ab3946c130cdc6591f4ade Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:30:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend):=20ajoute=20les=20comptes=20appli?= =?UTF-8?q?catifs=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", }