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:
parent
a19a90cea0
commit
14558d8747
@ -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
|
||||||
|
|||||||
129
apps/backend/scripts/setup/generate-admin-hash.js
Normal file
129
apps/backend/scripts/setup/generate-admin-hash.js
Normal 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);
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user