From 14558d874792bd8e8c46a4aa74a69b60f3776744 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:50 +0200 Subject: [PATCH] feat(auth): amorcer l administrateur depuis l environnement et neutraliser les comptes de test Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- apps/backend/.env.example | 24 +++ .../scripts/setup/generate-admin-hash.js | 129 ++++++++++++ .../migrations/1730000000007-SeedTestUsers.ts | 31 ++- ...0000-NeutralizeSeedAccountsInProduction.ts | 119 +++++++++++ .../1756000000001-BootstrapAdminFromEnv.ts | 190 ++++++++++++++++++ 5 files changed, 490 insertions(+), 3 deletions(-) create mode 100644 apps/backend/scripts/setup/generate-admin-hash.js create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000000-NeutralizeSeedAccountsInProduction.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000001-BootstrapAdminFromEnv.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index e12d10d..fa88ba5 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -91,3 +91,27 @@ STRIPE_GOLD_MONTHLY_PRICE_ID= STRIPE_GOLD_YEARLY_PRICE_ID= STRIPE_PLATINIUM_MONTHLY_PRICE_ID= STRIPE_PLATINIUM_YEARLY_PRICE_ID= + +# Premier administrateur (amorcage) - migration BootstrapAdminFromEnv +# En developpement, laissez vide : SeedTestUsers cree deja admin@xpeditis.com. +# En production, renseignez une adresse RELEVABLE : le compte est cree sans +# mot de passe utilisable et vous definissez le votre via "mot de passe oublie". +# BOOTSTRAP_ADMIN_EMAIL= +# BOOTSTRAP_ADMIN_FIRST_NAME=Admin +# BOOTSTRAP_ADMIN_LAST_NAME=Xpeditis +# BOOTSTRAP_ADMIN_ORG_NAME=Xpeditis +# BOOTSTRAP_ADMIN_ORG_STREET=A completer +# BOOTSTRAP_ADMIN_ORG_CITY=A completer +# BOOTSTRAP_ADMIN_ORG_POSTAL_CODE=00000 +# BOOTSTRAP_ADMIN_ORG_COUNTRY=FR +# Facultatif : hash Argon2id, si SMTP n'est pas encore operationnel. +# Generer avec : node scripts/setup/generate-admin-hash.js +# Jamais un mot de passe en clair - la migration le refuse. +# BOOTSTRAP_ADMIN_PASSWORD_HASH= + +# Force la neutralisation des comptes de demonstration hors production. +# FORCE_NEUTRALIZE_SEED_ACCOUNTS=true + +# Trade assistant — server only. Empty key enables guided help only. +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4.1-mini diff --git a/apps/backend/scripts/setup/generate-admin-hash.js b/apps/backend/scripts/setup/generate-admin-hash.js new file mode 100644 index 0000000..5add982 --- /dev/null +++ b/apps/backend/scripts/setup/generate-admin-hash.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/** + * Génère un hash Argon2id pour BOOTSTRAP_ADMIN_PASSWORD_HASH. + * + * cd apps/backend && node scripts/setup/generate-admin-hash.js + * + * Le mot de passe est saisi sans écho et ne quitte jamais votre poste : ni + * argument de ligne de commande (visible dans `ps` et dans l'historique du + * shell), ni variable d'environnement, ni fichier temporaire. + * + * RAPPEL — le mode SANS mot de passe est préférable. + * Si votre chaîne SMTP fonctionne, ne renseignez que BOOTSTRAP_ADMIN_EMAIL : + * le compte est alors créé sans mot de passe utilisable et vous le définissez + * via « mot de passe oublié ». Aucun secret n'existe nulle part, il n'y a donc + * rien à faire fuiter. Ce script n'est utile que si vous devez pouvoir vous + * connecter avant que l'envoi de courriels ne soit opérationnel. + */ + +'use strict'; + +const argon2 = require('argon2'); +const readline = require('readline'); + +// Mêmes paramètres que auth.service.ts : un hash produit ici est vérifiable +// par l'application sans aucune adaptation. +const ARGON2_OPTIONS = { + type: argon2.argon2id, + memoryCost: 65536, // 64 Mo + timeCost: 3, + parallelism: 4, +}; + +const MIN_LENGTH = 16; + +/** Saisie masquée sur un terminal ; lecture directe si l'entrée est redirigée. */ +function readSecret(prompt) { + return new Promise((resolve, reject) => { + if (!process.stdin.isTTY) { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => (data += chunk)); + process.stdin.on('end', () => resolve(data.replace(/\r?\n$/, ''))); + process.stdin.on('error', reject); + return; + } + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const onKeypress = () => { + // Réécrit la ligne sans révéler la longueur de la saisie. + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + process.stdout.write(prompt); + }; + + process.stdout.write(prompt); + process.stdin.on('data', onKeypress); + + rl.question('', answer => { + process.stdin.removeListener('data', onKeypress); + rl.close(); + process.stdout.write('\n'); + resolve(answer); + }); + }); +} + +function checkStrength(password) { + const problems = []; + if (password.length < MIN_LENGTH) { + problems.push(`au moins ${MIN_LENGTH} caractères (${password.length} fournis)`); + } + if (!/[a-z]/.test(password)) problems.push('une minuscule'); + if (!/[A-Z]/.test(password)) problems.push('une majuscule'); + if (!/[0-9]/.test(password)) problems.push('un chiffre'); + if (!/[^A-Za-z0-9]/.test(password)) problems.push('un caractère spécial'); + return problems; +} + +async function main() { + console.log(''); + console.log('Génération du hash Argon2id pour le premier administrateur.'); + console.log('La saisie n’est pas affichée.'); + console.log(''); + + const password = await readSecret('Mot de passe : '); + if (!password) { + console.error('Aucun mot de passe saisi.'); + process.exit(1); + } + + if (process.stdin.isTTY) { + const confirmation = await readSecret('Confirmation : '); + if (confirmation !== password) { + console.error('Les deux saisies diffèrent.'); + process.exit(1); + } + } + + const problems = checkStrength(password); + if (problems.length > 0) { + console.error(''); + console.error('Mot de passe refusé. Il manque : ' + problems.join(', ') + '.'); + console.error('Ce compte a tous les droits sur la plateforme : générez plutôt une'); + console.error('phrase longue et aléatoire depuis votre gestionnaire de mots de passe.'); + process.exit(1); + } + + const hash = await argon2.hash(password, ARGON2_OPTIONS); + + console.log(''); + console.log('Hash à placer dans le Secret Kubernetes (jamais dans le ConfigMap) :'); + console.log(''); + console.log(' BOOTSTRAP_ADMIN_PASSWORD_HASH: ' + JSON.stringify(hash)); + console.log(''); + console.log(' cd infra/prod && sops k8s/base/03-secrets.sops.yaml'); + console.log(''); + console.log('Après votre première connexion :'); + console.log(' 1. changez le mot de passe depuis l’interface ;'); + console.log(' 2. retirez BOOTSTRAP_ADMIN_PASSWORD_HASH du Secret et réappliquez.'); + console.log(''); + console.log('Un hash reste attaquable hors ligne : il n’a plus aucune raison'); + console.log('de rester stocké une fois le compte opérationnel.'); + console.log(''); +} + +main().catch(error => { + console.error('Échec :', error.message); + process.exit(1); +}); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts index 93ff9dd..fae4ef9 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts @@ -1,9 +1,26 @@ /** * Seed Test Users Migration * - * Seeds test users for development and testing - * Password for all users: Password123! - * Hash generated with Argon2id + * Comptes de test pour le developpement et la preprod. + * Mot de passe commun : Password123! (hash Argon2id ci-dessous) + * + * NE S'EXECUTE JAMAIS EN PRODUCTION + * --------------------------------- + * Ce fichier contient un mot de passe en clair pour un compte ADMIN. Sur une + * base de production, l'appliquer creerait un administrateur aux identifiants + * publics, connus de quiconque a lu le depot. La garde NODE_ENV ci-dessous + * l'en empeche. + * + * Le corps de la migration a ete modifie apres son ecriture initiale, ce qui + * deroge a la regle "ne jamais modifier une migration appliquee". C'est sans + * consequence ici : TypeORM suit les migrations par NOM de classe et ne + * recalcule aucune empreinte. Les bases ou elle a deja tourne (dev, preprod) ne + * la rejouent pas et gardent leurs comptes de test ; seules les bases neuves + * voient la garde s'appliquer. + * + * Filet de securite pour les bases ou elle aurait deja tourne : + * migration 1756000000000-NeutralizeSeedAccountsInProduction. + * Creation d'un vrai administrateur : 1756000000001-BootstrapAdminFromEnv. */ import { MigrationInterface, QueryRunner } from 'typeorm'; @@ -11,6 +28,14 @@ import { DEFAULT_ORG_ID } from '../seeds/test-organizations.seed'; export class SeedTestUsers1730000000007 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { + if (process.env.NODE_ENV === 'production') { + console.log( + 'SeedTestUsers ignore : NODE_ENV=production. ' + + 'Utilisez BOOTSTRAP_ADMIN_EMAIL pour creer le premier administrateur.' + ); + return; + } + // Use fixed organization ID from seed const organizationId = DEFAULT_ORG_ID; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000000-NeutralizeSeedAccountsInProduction.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000000-NeutralizeSeedAccountsInProduction.ts new file mode 100644 index 0000000..9e152af --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000000-NeutralizeSeedAccountsInProduction.ts @@ -0,0 +1,119 @@ +/** + * Neutralise les comptes de démonstration en production. + * + * POURQUOI + * -------- + * La migration 1730000000007-SeedTestUsers insère trois comptes dont le mot de + * passe (`Password123!`) est écrit en clair dans le dépôt, dont un ADMIN. + * Sur une base de production neuve, appliquer les migrations créait donc un + * administrateur aux identifiants publics. + * + * SeedTestUsers ne s'exécute désormais plus en production (garde ajoutée dans + * cette même migration). Ce filet de sécurité couvre les cas restants : + * - une base de production migrée avant l'ajout de la garde ; + * - un environnement où NODE_ENV n'était pas correctement positionné ; + * - une restauration à partir d'une sauvegarde antérieure. + * + * Les lignes ne sont PAS supprimées : `audit_logs` et d'autres tables peuvent y + * référer, et une suppression en cascade ferait plus de dégâts que de bien. + * Les comptes sont renommés (ce qui libère `admin@xpeditis.com` pour votre vrai + * compte), rendus impossibles à authentifier, et désactivés. + * + * Idempotente : une seconde exécution ne trouve plus rien à faire. + * + * En développement et en preprod, cette migration ne fait rien — les comptes de + * test restent utilisables. Pour l'y forcer malgré tout : + * FORCE_NEUTRALIZE_SEED_ACCOUNTS=true + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; +import * as crypto from 'crypto'; +import * as argon2 from 'argon2'; + +/** Paramètres Argon2id du projet (cf. auth.service.ts). */ +const ARGON2_OPTIONS = { + type: argon2.argon2id, + memoryCost: 65536, + timeCost: 3, + parallelism: 4, +} as const; + +const SEED_ACCOUNTS = ['admin@xpeditis.com', 'manager@xpeditis.com', 'user@xpeditis.com']; + +/** + * Produit un hash Argon2id valide d'un secret aléatoire immédiatement perdu. + * + * Un hash *syntaxiquement valide* est indispensable : `auth.service.ts` appelle + * `argon2.verify()` sans try/catch, et une chaîne malformée lèverait une + * exception — donc un 500 au lieu du 401 attendu. + */ +async function unusablePasswordHash(): Promise { + return argon2.hash(crypto.randomBytes(48).toString('hex'), ARGON2_OPTIONS); +} + +export class NeutralizeSeedAccountsInProduction1756000000000 implements MigrationInterface { + name = 'NeutralizeSeedAccountsInProduction1756000000000'; + + public async up(queryRunner: QueryRunner): Promise { + const isProduction = process.env.NODE_ENV === 'production'; + const forced = process.env.FORCE_NEUTRALIZE_SEED_ACCOUNTS === 'true'; + + if (!isProduction && !forced) { + console.log('[neutralisation] NODE_ENV != production : comptes de démonstration conservés.'); + return; + } + + const rows: Array<{ id: string; email: string }> = await queryRunner.query( + `SELECT "id", "email" FROM "users" WHERE "email" = ANY($1)`, + [SEED_ACCOUNTS] + ); + + if (rows.length === 0) { + console.log('[neutralisation] Aucun compte de démonstration présent.'); + return; + } + + for (const row of rows) { + // Le nouveau libellé respecte la contrainte chk_users_email + // (LOWER(email) = email) : les UUID sont en minuscules. + const disabledEmail = `seed-disabled-${String(row.id).slice(0, 8)}@invalid.local`; + + await queryRunner.query( + `UPDATE "users" + SET "email" = $1, + "password_hash" = $2, + "is_active" = false, + "updated_at" = NOW() + WHERE "id" = $3`, + [disabledEmail, await unusablePasswordHash(), row.id] + ); + + console.log(`[neutralisation] ${row.email} -> ${disabledEmail} (désactivé)`); + } + + // Contrôle explicite : la migration échoue plutôt que de laisser croire + // que le nettoyage a eu lieu. + const remaining: Array<{ n: number }> = await queryRunner.query( + `SELECT count(*)::int AS n FROM "users" WHERE "email" = ANY($1)`, + [SEED_ACCOUNTS] + ); + + if (remaining[0].n > 0) { + throw new Error( + `Neutralisation incomplète : ${remaining[0].n} compte(s) de démonstration subsistent.` + ); + } + + console.log(`[neutralisation] ${rows.length} compte(s) neutralisé(s).`); + } + + public async down(): Promise { + // Volontairement sans effet. + // + // Restaurer des comptes dont le mot de passe est public serait une + // régression de sécurité déclenchée par un simple `migration:revert`. + // Si vous avez réellement besoin des comptes de démonstration, recréez-les + // dans un environnement non productif. + console.log('[neutralisation] down() sans effet — par conception.'); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000001-BootstrapAdminFromEnv.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000001-BootstrapAdminFromEnv.ts new file mode 100644 index 0000000..b2deb33 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000001-BootstrapAdminFromEnv.ts @@ -0,0 +1,190 @@ +/** + * Crée le premier administrateur à partir de l'environnement. + * + * Remplace le compte `admin@xpeditis.com / Password123!` de la migration de + * démonstration : on garde la commodité (une base neuve arrive avec un + * administrateur utilisable) sans le mot de passe public. + * + * DEUX MODES + * ---------- + * + * 1. SANS MOT DE PASSE — recommandé. + * BOOTSTRAP_ADMIN_EMAIL=vous@votredomaine.fr + * + * Le compte est créé avec un hash Argon2id d'un secret aléatoire + * immédiatement perdu : personne, pas même vous, ne peut s'y connecter. + * Vous définissez votre mot de passe via « mot de passe oublié », qui envoie + * un jeton à usage unique, valable 1 heure, stocké haché en base. + * + * Aucun secret n'existe donc nulle part : ni dans Git, ni dans le Secret + * Kubernetes, ni dans l'historique du shell, ni dans les journaux de + * migration. C'est la seule variante où il n'y a rien à faire fuiter. + * Effet de bord utile : la réception du courriel prouve que la chaîne SMTP + * fonctionne. + * + * 2. AVEC UN HASH PRÉ-CALCULÉ — si SMTP n'est pas encore opérationnel. + * BOOTSTRAP_ADMIN_EMAIL=vous@votredomaine.fr + * BOOTSTRAP_ADMIN_PASSWORD_HASH=$argon2id$v=19$m=65536,t=3,p=4$... + * + * Le hash se génère hors ligne : + * node apps/backend/scripts/setup/generate-admin-hash.js + * Le mot de passe en clair ne quitte jamais votre poste. Le hash, lui, reste + * sensible (attaque hors ligne possible) : utilisez un mot de passe long et + * aléatoire, changez-le après la première connexion, puis retirez la + * variable du Secret. + * + * GARDE-FOUS + * ---------- + * - Sans BOOTSTRAP_ADMIN_EMAIL, la migration ne fait rien. + * - S'il existe déjà un ADMIN actif, la migration ne fait rien : elle ne peut + * donc pas créer un second administrateur à votre insu lors d'un déploiement + * ultérieur. + * - Si un compte porte déjà cette adresse, il est promu ADMIN sans que son + * mot de passe ne soit touché. + * - Un mot de passe en clair passé par erreur dans + * BOOTSTRAP_ADMIN_PASSWORD_HASH est refusé : la migration échoue. + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; +import * as crypto from 'crypto'; +import * as argon2 from 'argon2'; + +/** Paramètres Argon2id du projet (cf. auth.service.ts). */ +const ARGON2_OPTIONS = { + type: argon2.argon2id, + memoryCost: 65536, + timeCost: 3, + parallelism: 4, +} as const; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function env(name: string, fallback = ''): string { + return (process.env[name] ?? fallback).trim(); +} + +export class BootstrapAdminFromEnv1756000000001 implements MigrationInterface { + name = 'BootstrapAdminFromEnv1756000000001'; + + public async up(queryRunner: QueryRunner): Promise { + const email = env('BOOTSTRAP_ADMIN_EMAIL').toLowerCase(); + + if (!email) { + console.log( + '[amorçage admin] BOOTSTRAP_ADMIN_EMAIL absent : aucun administrateur créé. ' + + 'Inscrivez-vous par l’interface puis promouvez le compte en base.' + ); + return; + } + + if (!EMAIL_PATTERN.test(email)) { + throw new Error(`[amorçage admin] BOOTSTRAP_ADMIN_EMAIL invalide : "${email}"`); + } + + // Ne jamais créer un second administrateur silencieusement. + const activeAdmins: Array<{ n: number }> = await queryRunner.query( + `SELECT count(*)::int AS n FROM "users" WHERE "role" = 'ADMIN' AND "is_active" = true` + ); + if (activeAdmins[0].n > 0) { + console.log( + `[amorçage admin] ${activeAdmins[0].n} administrateur(s) actif(s) déjà présent(s) : rien à faire.` + ); + return; + } + + // --- Compte déjà existant : promotion, sans toucher au mot de passe ------ + const existing: Array<{ id: string }> = await queryRunner.query( + `SELECT "id" FROM "users" WHERE "email" = $1`, + [email] + ); + + if (existing.length > 0) { + await queryRunner.query( + `UPDATE "users" + SET "role" = 'ADMIN', "is_active" = true, "updated_at" = NOW() + WHERE "id" = $1`, + [existing[0].id] + ); + console.log(`[amorçage admin] Compte existant ${email} promu ADMIN (mot de passe inchangé).`); + return; + } + + // --- Organisation de rattachement --------------------------------------- + // users.organization_id est NOT NULL avec clé étrangère : il faut une + // organisation avant de pouvoir créer l'administrateur. + const orgName = env('BOOTSTRAP_ADMIN_ORG_NAME', 'Xpeditis'); + const orgCountry = env('BOOTSTRAP_ADMIN_ORG_COUNTRY', 'FR').toUpperCase(); + + if (!/^[A-Z]{2}$/.test(orgCountry)) { + throw new Error( + `[amorçage admin] BOOTSTRAP_ADMIN_ORG_COUNTRY doit être un code ISO à 2 lettres, reçu "${orgCountry}"` + ); + } + + const org: Array<{ id: string }> = await queryRunner.query( + `INSERT INTO "organizations" + ("name", "type", "address_street", "address_city", "address_postal_code", "address_country") + VALUES ($1, 'FREIGHT_FORWARDER', $2, $3, $4, $5) + ON CONFLICT ("name") DO UPDATE SET "updated_at" = NOW() + RETURNING "id"`, + [ + orgName, + env('BOOTSTRAP_ADMIN_ORG_STREET', 'A completer'), + env('BOOTSTRAP_ADMIN_ORG_CITY', 'A completer'), + env('BOOTSTRAP_ADMIN_ORG_POSTAL_CODE', '00000'), + orgCountry, + ] + ); + const organizationId = org[0].id; + + // --- Mot de passe -------------------------------------------------------- + const providedHash = env('BOOTSTRAP_ADMIN_PASSWORD_HASH'); + let passwordHash: string; + let mode: string; + + if (providedHash) { + if (!providedHash.startsWith('$argon2')) { + throw new Error( + '[amorçage admin] BOOTSTRAP_ADMIN_PASSWORD_HASH doit contenir un hash Argon2 ' + + '(commençant par "$argon2"), jamais un mot de passe en clair. ' + + 'Générez-le avec scripts/setup/generate-admin-hash.js.' + ); + } + passwordHash = providedHash; + mode = 'hash fourni par l’environnement'; + } else { + // Hash d'un secret aléatoire immédiatement perdu : le compte existe, il + // est actif, mais aucun mot de passe ne peut y correspondre. + passwordHash = await argon2.hash(crypto.randomBytes(48).toString('hex'), ARGON2_OPTIONS); + mode = 'aucun mot de passe — à définir via « mot de passe oublié »'; + } + + await queryRunner.query( + `INSERT INTO "users" + ("organization_id", "email", "password_hash", "role", + "first_name", "last_name", "is_email_verified", "is_active") + VALUES ($1, $2, $3, 'ADMIN', $4, $5, true, true)`, + [ + organizationId, + email, + passwordHash, + env('BOOTSTRAP_ADMIN_FIRST_NAME', 'Admin'), + env('BOOTSTRAP_ADMIN_LAST_NAME', 'Xpeditis'), + ] + ); + + console.log(`[amorçage admin] Administrateur ${email} créé (${mode}).`); + if (!providedHash) { + console.log( + '[amorçage admin] Étape suivante : POST /api/v1/auth/forgot-password avec cette adresse, ' + + 'puis suivez le lien reçu par courriel pour définir le mot de passe.' + ); + } + } + + public async down(): Promise { + // Volontairement sans effet : supprimer l'unique administrateur d'une + // production sur un `migration:revert` serait pire que le problème résolu. + console.log('[amorçage admin] down() sans effet — par conception.'); + } +}