feat(auth): amorcer l administrateur depuis l environnement et neutraliser les comptes de test

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
This commit is contained in:
David 2026-09-07 21:40:50 +02:00
parent a19a90cea0
commit 14558d8747
5 changed files with 490 additions and 3 deletions

View File

@ -91,3 +91,27 @@ STRIPE_GOLD_MONTHLY_PRICE_ID=
STRIPE_GOLD_YEARLY_PRICE_ID= STRIPE_GOLD_YEARLY_PRICE_ID=
STRIPE_PLATINIUM_MONTHLY_PRICE_ID= STRIPE_PLATINIUM_MONTHLY_PRICE_ID=
STRIPE_PLATINIUM_YEARLY_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

View File

@ -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);
});

View File

@ -1,9 +1,26 @@
/** /**
* Seed Test Users Migration * Seed Test Users Migration
* *
* Seeds test users for development and testing * Comptes de test pour le developpement et la preprod.
* Password for all users: Password123! * Mot de passe commun : Password123! (hash Argon2id ci-dessous)
* Hash generated with Argon2id *
* 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'; import { MigrationInterface, QueryRunner } from 'typeorm';
@ -11,6 +28,14 @@ import { DEFAULT_ORG_ID } from '../seeds/test-organizations.seed';
export class SeedTestUsers1730000000007 implements MigrationInterface { export class SeedTestUsers1730000000007 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
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 // Use fixed organization ID from seed
const organizationId = DEFAULT_ORG_ID; const organizationId = DEFAULT_ORG_ID;

View File

@ -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<string> {
return argon2.hash(crypto.randomBytes(48).toString('hex'), ARGON2_OPTIONS);
}
export class NeutralizeSeedAccountsInProduction1756000000000 implements MigrationInterface {
name = 'NeutralizeSeedAccountsInProduction1756000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
// 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.');
}
}

View File

@ -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<void> {
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<void> {
// 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.');
}
}