172 lines
5.8 KiB
Python
172 lines
5.8 KiB
Python
# 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 json
|
|
import secrets
|
|
import string
|
|
import sys
|
|
from getpass import getpass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from pydantic import SecretStr
|
|
|
|
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.main import create_app
|
|
from app.repositories.user import UserRepository
|
|
from app.schemas.auth import PASSWORD_MIN_LENGTH, valide_complexite
|
|
|
|
LONGUEUR_MOT_DE_PASSE_GENERE = 24
|
|
CARACTERES_SPECIAUX = "!@#$%^&*()-_=+[]{};:,.?"
|
|
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
|
|
|
|
|
|
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",
|
|
)
|
|
|
|
|
|
# Piège : le schéma ne doit dépendre ni du `.env` du poste ni des variables `APP_*`, sinon le
|
|
# fichier versionné changerait de machine en machine et le test de dérive deviendrait un oracle
|
|
# de configuration locale. Tout ce qui atteint le schéma est donc posé ici, `_env_file` compris.
|
|
def settings_du_contrat() -> Settings:
|
|
return Settings(
|
|
_env_file=None,
|
|
name="EnerVision API",
|
|
version="0.1.0",
|
|
env="local",
|
|
api_prefix="/api/v1",
|
|
secret_key=SecretStr("contrat-openapi-sans-effet-sur-le-schema"),
|
|
database_url="postgresql+asyncpg://openapi:contrat@localhost:5432/enervision",
|
|
)
|
|
|
|
|
|
def schema_du_contrat() -> dict[str, Any]:
|
|
schema: dict[str, Any] = create_app(settings_du_contrat()).openapi()
|
|
return schema
|
|
|
|
|
|
def rend_le_contrat() -> str:
|
|
return json.dumps(schema_du_contrat(), indent=2, ensure_ascii=False) + "\n"
|
|
|
|
|
|
def export_openapi(destination: Path) -> str:
|
|
destination.write_text(rend_le_contrat(), encoding="utf-8")
|
|
return f"Contrat OpenAPI écrit dans {destination}"
|
|
|
|
|
|
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"
|
|
)
|
|
|
|
contrat = sous_commandes.add_parser(
|
|
"export-openapi", help="Écrit le contrat OpenAPI sur disque"
|
|
)
|
|
contrat.add_argument("--output", default=str(CHEMIN_CONTRAT))
|
|
return parser
|
|
|
|
|
|
def genere_mot_de_passe() -> str:
|
|
tirage = secrets.SystemRandom()
|
|
classes = [
|
|
string.ascii_uppercase,
|
|
string.ascii_lowercase,
|
|
string.digits,
|
|
CARACTERES_SPECIAUX,
|
|
]
|
|
reste = LONGUEUR_MOT_DE_PASSE_GENERE - len(classes)
|
|
caracteres = [tirage.choice(classe) for classe in classes]
|
|
caracteres += [tirage.choice("".join(classes)) for _ in range(reste)]
|
|
tirage.shuffle(caracteres)
|
|
return "".join(caracteres)
|
|
|
|
|
|
def read_password(*, generate: bool) -> str:
|
|
if generate:
|
|
mot_de_passe = genere_mot_de_passe()
|
|
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) < PASSWORD_MIN_LENGTH:
|
|
raise SystemExit(f"Le mot de passe doit faire au moins {PASSWORD_MIN_LENGTH} caractères")
|
|
try:
|
|
valide_complexite(mot_de_passe)
|
|
except ValueError as erreur:
|
|
raise SystemExit(str(erreur)) from erreur
|
|
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)
|
|
|
|
if arguments.commande == "export-openapi":
|
|
print(export_openapi(Path(arguments.output)))
|
|
return 0
|
|
|
|
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())
|