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:
@@ -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 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:
|
||||
|
||||
@@ -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
|
||||
# --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())
|
||||
)
|
||||
Reference in New Issue
Block a user