Remplace la regle de longueur seule (12 caracteres) par une exigence de composition (8 caracteres minimum, majuscule, minuscule, chiffre, caractere special), non documentee dans les exigences officielles du projet, par une regle explicite partagee entre le backend (validateur Pydantic) et le frontend. Ajoute un flux "mot de passe oublie" en libre-service, absent jusqu'ici : jeton a usage unique hache en base (meme principe que les refresh tokens), expirant a 15 minutes, envoye par email via un service SMTP (aiosmtplib, Mailpit en dev), avec limitation de debit dediee et reponse generique pour eviter l'enumeration des comptes. Closes #87
124 lines
4.7 KiB
Python
124 lines
4.7 KiB
Python
from functools import lru_cache
|
|
from typing import Literal, Self
|
|
|
|
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
|
|
REFRESH_COOKIE_DEFAUT = "ev_refresh"
|
|
SENTINELLES_INTERDITES = frozenset(
|
|
{"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"}
|
|
)
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(
|
|
env_file=".env",
|
|
env_prefix="APP_",
|
|
env_file_encoding="utf-8",
|
|
extra="ignore",
|
|
)
|
|
|
|
name: str = "EnerVision API"
|
|
version: str = "0.1.0"
|
|
env: Environment = "local"
|
|
debug: bool = False
|
|
log_level: str = "INFO"
|
|
api_prefix: str = "/api/v1"
|
|
secret_key: SecretStr
|
|
cors_origins: str = ""
|
|
database_url: str = Field(validation_alias="DATABASE_URL")
|
|
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 = REFRESH_COOKIE_DEFAUT
|
|
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)
|
|
|
|
password_reset_ttl_seconds: int = Field(default=900, ge=60, le=3600)
|
|
password_reset_window_seconds: int = Field(default=900, ge=60)
|
|
password_reset_max_requests_per_identifier: int = Field(default=3, ge=1)
|
|
password_reset_max_requests_per_ip: int = Field(default=10, ge=1)
|
|
|
|
smtp_host: str = "localhost"
|
|
smtp_port: int = Field(default=587, ge=1, le=65535)
|
|
smtp_username: str | None = None
|
|
smtp_password: SecretStr | None = None
|
|
smtp_use_tls: bool = False
|
|
smtp_from_address: str = "no-reply@enervision.fr"
|
|
frontend_reset_password_url: str = "http://localhost:4200/reset-password" # noqa: S105
|
|
|
|
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()]
|
|
|
|
@property
|
|
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:
|
|
return Settings()
|