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.
55 lines
1.5 KiB
Python
55 lines
1.5 KiB
Python
# 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
|