feat(backend): ajoute les comptes applicatifs et l'amorçage du premier admin
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.
This commit is contained in:
+7
-5
@@ -1,17 +1,19 @@
|
|||||||
# Variables lues par docker-compose.yml a la racine.
|
# Variables lues par docker-compose.yml à la racine.
|
||||||
# Le backend lance hors conteneur (`make dev`) lit apps/backend/.env, pas ce fichier.
|
# Le backend lancé hors conteneur (`make dev`) lit apps/backend/.env, pas ce fichier.
|
||||||
|
|
||||||
POSTGRES_USER=enervision
|
POSTGRES_USER=enervision
|
||||||
POSTGRES_PASSWORD=change_me
|
POSTGRES_PASSWORD=change_me
|
||||||
POSTGRES_DB=enervision
|
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
|
POSTGRES_PORT=5433
|
||||||
# `basic` renvoie des statistiques d'usage a Timescale.
|
# `basic` renvoie des statistiques d'usage à Timescale.
|
||||||
TIMESCALEDB_TELEMETRY=off
|
TIMESCALEDB_TELEMETRY=off
|
||||||
|
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_DEBUG=true
|
APP_DEBUG=false
|
||||||
APP_LOG_LEVEL=INFO
|
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_SECRET_KEY=change_me
|
||||||
APP_CORS_ORIGINS=http://localhost:4200
|
APP_CORS_ORIGINS=http://localhost:4200
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ BACKEND := apps/backend
|
|||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help install dev lint format typecheck test test-cov test-integration check \
|
.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
|
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}'
|
@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
|
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
|
cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
lint: ## Analyse statique du backend
|
lint: ## Analyse statique du backend
|
||||||
@@ -19,31 +19,31 @@ lint: ## Analyse statique du backend
|
|||||||
format: ## Formate et corrige le backend
|
format: ## Formate et corrige le backend
|
||||||
cd $(BACKEND) && uv run ruff format . && uv run ruff check --fix .
|
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
|
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
|
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 \
|
cd $(BACKEND) && uv run pytest --cov-fail-under=85 --cov-report=html \
|
||||||
--cov-report=xml --junitxml=test-results/junit.xml
|
--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
|
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: ## Construit l'image du backend
|
||||||
docker build -t enervision-backend:local $(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
|
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
|
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
|
docker compose down -v && docker compose up -d db
|
||||||
|
|
||||||
db-logs: ## Suit les journaux de la base
|
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
|
migrate: ## Applique les migrations Alembic
|
||||||
cd $(BACKEND) && uv run alembic upgrade head
|
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}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_DEBUG=true
|
APP_DEBUG=false
|
||||||
APP_LOG_LEVEL=INFO
|
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_SECRET_KEY=change_me
|
||||||
|
|
||||||
APP_CORS_ORIGINS=http://localhost:4200
|
APP_CORS_ORIGINS=http://localhost:4200
|
||||||
DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision
|
DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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())
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
from functools import lru_cache
|
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
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
Environment = Literal["local", "dev", "staging", "prod"]
|
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):
|
class Settings(BaseSettings):
|
||||||
@@ -27,6 +33,30 @@ class Settings(BaseSettings):
|
|||||||
database_pool_size: int = 5
|
database_pool_size: int = 5
|
||||||
database_max_overflow: int = 10
|
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
|
@property
|
||||||
def allowed_origins(self) -> list[str]:
|
def allowed_origins(self) -> list[str]:
|
||||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
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:
|
def is_production(self) -> bool:
|
||||||
return self.env == "prod"
|
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
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
# Piège : tout modèle absent de ce module reste invisible de `alembic revision
|
# 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.
|
# --autogenerate`, qui générerait alors un drop de sa table.
|
||||||
|
|
||||||
|
from app.models.user import AppUser
|
||||||
|
|
||||||
|
__all__ = ["AppUser"]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
|
)
|
||||||
@@ -22,7 +22,7 @@ def environment() -> Iterator[None]:
|
|||||||
"APP_DEBUG": "false",
|
"APP_DEBUG": "false",
|
||||||
"APP_LOG_LEVEL": "WARNING",
|
"APP_LOG_LEVEL": "WARNING",
|
||||||
"APP_CORS_ORIGINS": "",
|
"APP_CORS_ORIGINS": "",
|
||||||
"APP_SECRET_KEY": "secret-de-test",
|
"APP_SECRET_KEY": "secret-de-test-assez-long-pour-le-validateur",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
os.environ.setdefault(
|
os.environ.setdefault(
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -7,7 +7,7 @@ SETTINGS_DE_TEST: dict[str, Any] = {
|
|||||||
"debug": False,
|
"debug": False,
|
||||||
"log_level": "WARNING",
|
"log_level": "WARNING",
|
||||||
"cors_origins": "",
|
"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",
|
"database_url": "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user