fix admin monter
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m32s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m20s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Unit Tests (push) Successful in 10m45s
Dev CI / Notify Failure (push) Has been skipped
CD Preprod / Backend — Lint (push) Successful in 10m24s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 11m18s
CD Preprod / Backend — Unit Tests (push) Successful in 10m16s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m45s
CD Preprod / Backend — Integration Tests (push) Successful in 9m56s
CD Preprod / Build Frontend (push) Successful in 56s
CD Preprod / Build Log Exporter (push) Successful in 32s
CD Preprod / Build Backend (push) Successful in 6m48s
CD Preprod / Deploy to Preprod (push) Successful in 23s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 2s
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m32s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m20s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Unit Tests (push) Successful in 10m45s
Dev CI / Notify Failure (push) Has been skipped
CD Preprod / Backend — Lint (push) Successful in 10m24s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 11m18s
CD Preprod / Backend — Unit Tests (push) Successful in 10m16s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m45s
CD Preprod / Backend — Integration Tests (push) Successful in 9m56s
CD Preprod / Build Frontend (push) Successful in 56s
CD Preprod / Build Log Exporter (push) Successful in 32s
CD Preprod / Build Backend (push) Successful in 6m48s
CD Preprod / Deploy to Preprod (push) Successful in 23s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 2s
This commit is contained in:
parent
10bc0cf898
commit
10b69f3b2b
@ -61,6 +61,10 @@ COPY --from=builder --chown=nestjs:nodejs /app/src ./src
|
||||
# Copy startup script (includes migrations)
|
||||
COPY --chown=nestjs:nodejs scripts/setup/startup.js ./startup.js
|
||||
|
||||
# Recovery command: set an admin password without SMTP
|
||||
# docker exec -it <backend-container> node admin-password.js <email>
|
||||
COPY --chown=nestjs:nodejs scripts/setup/admin-password.js ./admin-password.js
|
||||
|
||||
# Create logs and uploads directories
|
||||
RUN mkdir -p /app/logs && \
|
||||
mkdir -p /app/src/infrastructure/storage/csv-storage/rates && \
|
||||
|
||||
167
apps/backend/scripts/setup/admin-password.js
Normal file
167
apps/backend/scripts/setup/admin-password.js
Normal file
@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Commande de secours : définit le mot de passe d'un administrateur, sans SMTP.
|
||||
*
|
||||
* Dans le conteneur backend (le script est copié dans l'image) :
|
||||
* docker exec -it <conteneur-backend> node admin-password.js admin@xpeditis.com
|
||||
* En local :
|
||||
* cd apps/backend && node scripts/setup/admin-password.js admin@xpeditis.com
|
||||
*
|
||||
* Le compte est créé s'il n'existe pas, sinon promu ADMIN et réactivé, puis son
|
||||
* mot de passe est remplacé. Le mot de passe est saisi sans écho : ni argument
|
||||
* de ligne de commande (visible dans `ps` et l'historique du shell), ni
|
||||
* variable d'environnement. Il peut aussi être passé sur l'entrée standard
|
||||
* pour une exécution scriptée.
|
||||
*
|
||||
* Connexion à la base : variables DATABASE_* déjà présentes dans le conteneur.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const readline = require('readline');
|
||||
const argon2 = require('argon2');
|
||||
const { Client } = require('pg');
|
||||
|
||||
// Mêmes paramètres que auth.service.ts.
|
||||
const ARGON2_OPTIONS = { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 4 };
|
||||
const MIN_LENGTH = 12;
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/** 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 = () => {
|
||||
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 strengthProblems(password) {
|
||||
const problems = [];
|
||||
if (password.length < MIN_LENGTH) problems.push(`au moins ${MIN_LENGTH} caractères`);
|
||||
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');
|
||||
return problems;
|
||||
}
|
||||
|
||||
function env(name, fallback) {
|
||||
const value = (process.env[name] || '').trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const email = (process.argv[2] || '').trim().toLowerCase();
|
||||
if (!EMAIL_PATTERN.test(email)) {
|
||||
console.error('Usage : node admin-password.js <email-administrateur>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const password = await readSecret(`Nouveau mot de passe pour ${email} : `);
|
||||
if (process.stdin.isTTY) {
|
||||
const confirmation = await readSecret('Confirmation : ');
|
||||
if (confirmation !== password) {
|
||||
console.error('Les deux saisies diffèrent.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const problems = strengthProblems(password);
|
||||
if (problems.length > 0) {
|
||||
console.error(`Mot de passe refusé. Il manque : ${problems.join(', ')}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(password, ARGON2_OPTIONS);
|
||||
|
||||
const client = new Client({
|
||||
host: env('DATABASE_HOST', 'localhost'),
|
||||
port: Number(env('DATABASE_PORT', '5432')),
|
||||
user: env('DATABASE_USER', 'xpeditis'),
|
||||
password: process.env.DATABASE_PASSWORD,
|
||||
database: env('DATABASE_NAME', 'xpeditis_dev'),
|
||||
// Même règle que data-source.ts : en production, pg_hba n'accepte que SSL.
|
||||
ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const existing = await client.query('SELECT "id" FROM "users" WHERE "email" = $1', [email]);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// Réactiver ou promouvoir n'est jamais bloqué par le déclencheur
|
||||
// « au moins un administrateur actif ».
|
||||
await client.query(
|
||||
`UPDATE "users"
|
||||
SET "password_hash" = $2, "role" = 'ADMIN', "is_active" = true, "updated_at" = NOW()
|
||||
WHERE "id" = $1`,
|
||||
[existing.rows[0].id, passwordHash]
|
||||
);
|
||||
console.log(`Compte ${email} : mot de passe défini, rôle ADMIN, compte actif.`);
|
||||
} else {
|
||||
const organization = await client.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"`,
|
||||
[
|
||||
env('BOOTSTRAP_ADMIN_ORG_NAME', 'Xpeditis'),
|
||||
env('BOOTSTRAP_ADMIN_ORG_STREET', 'A completer'),
|
||||
env('BOOTSTRAP_ADMIN_ORG_CITY', 'A completer'),
|
||||
env('BOOTSTRAP_ADMIN_ORG_POSTAL_CODE', '00000'),
|
||||
env('BOOTSTRAP_ADMIN_ORG_COUNTRY', 'FR').toUpperCase(),
|
||||
]
|
||||
);
|
||||
await client.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)`,
|
||||
[
|
||||
organization.rows[0].id,
|
||||
email,
|
||||
passwordHash,
|
||||
env('BOOTSTRAP_ADMIN_FIRST_NAME', 'Admin'),
|
||||
env('BOOTSTRAP_ADMIN_LAST_NAME', 'Xpeditis'),
|
||||
]
|
||||
);
|
||||
console.log(`Administrateur ${email} créé et actif.`);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
console.log('Vous pouvez vous connecter. Changez ce mot de passe depuis l’interface si besoin.');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('Échec :', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -8,12 +8,15 @@
|
||||
* 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.
|
||||
* Le hash est appliqué à CHAQUE lancement du backend (AdminBootstrapService) :
|
||||
* - au compte BOOTSTRAP_ADMIN_EMAIL s'il ne s'est encore jamais connecté ;
|
||||
* - à tout compte si BOOTSTRAP_ADMIN_RESET_PASSWORD=true (retirez ensuite
|
||||
* les deux variables, sinon un mot de passe changé depuis l'interface
|
||||
* serait remplacé au lancement suivant).
|
||||
*
|
||||
* Sans hash, le compte est créé sans mot de passe utilisable : il faut alors
|
||||
* « mot de passe oublié » (SMTP requis) ou, sans SMTP, la commande de secours :
|
||||
* docker exec -it <conteneur-backend> node admin-password.js <email>
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
@ -39,6 +39,7 @@ import { CacheModule } from './infrastructure/cache/cache.module';
|
||||
import { CarrierModule } from './infrastructure/carriers/carrier.module';
|
||||
import { SecurityModule } from './infrastructure/security/security.module';
|
||||
import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module';
|
||||
import { AdminBootstrapModule } from './infrastructure/persistence/typeorm/admin-bootstrap.module';
|
||||
|
||||
// Import global guards
|
||||
import { ApiKeyOrJwtGuard } from './application/guards/api-key-or-jwt.guard';
|
||||
@ -97,6 +98,13 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(),
|
||||
STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(),
|
||||
LOG_EXPORTER_URL: Joi.string().uri().default('http://xpeditis-log-exporter:3200'),
|
||||
// Administrateur garanti a chaque lancement (AdminBootstrapService).
|
||||
// Pas de .email() ici : Joi rejette les TLD hors liste IANA et une
|
||||
// adresse refusee empecherait l'API entiere de demarrer. Le service
|
||||
// valide l'adresse et journalise une erreur sans bloquer.
|
||||
BOOTSTRAP_ADMIN_EMAIL: Joi.string().allow('').optional(),
|
||||
BOOTSTRAP_ADMIN_PASSWORD_HASH: Joi.string().allow('').optional(),
|
||||
BOOTSTRAP_ADMIN_RESET_PASSWORD: Joi.string().valid('true', 'false', '').default('false'),
|
||||
}),
|
||||
}),
|
||||
|
||||
@ -183,6 +191,8 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
CacheModule,
|
||||
CarrierModule,
|
||||
CsvRateModule,
|
||||
// Un administrateur exploitable a chaque lancement (base neuve comprise)
|
||||
AdminBootstrapModule,
|
||||
|
||||
// Feature modules
|
||||
AuthModule,
|
||||
|
||||
@ -207,6 +207,16 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// last_login_at n'etait jamais renseigne. L'amorcage de l'administrateur
|
||||
// s'en sert pour ne jamais ecraser le mot de passe d'un compte deja utilise.
|
||||
try {
|
||||
user.recordLogin();
|
||||
await this.userRepository.save(user);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Could not record last login for ${email}: ${message}`);
|
||||
}
|
||||
|
||||
const tokens = await this.generateTokens(user, rememberMe);
|
||||
|
||||
this.logger.log(`User logged in successfully: ${email}`);
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AdminBootstrapService } from './admin-bootstrap.service';
|
||||
|
||||
/**
|
||||
* Garantit un administrateur exploitable à chaque lancement du backend
|
||||
* (voir admin-bootstrap.service.ts). Le DataSource vient de TypeOrmModule.forRoot.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [AdminBootstrapService],
|
||||
})
|
||||
export class AdminBootstrapModule {}
|
||||
@ -0,0 +1,192 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import {
|
||||
AdminBootstrapService,
|
||||
decideAdminBootstrap,
|
||||
ExistingAccount,
|
||||
readAdminBootstrapConfig,
|
||||
} from './admin-bootstrap.service';
|
||||
|
||||
const HASH = '$argon2id$v=19$m=65536,t=3,p=4$c2VsLWRlLXRlc3Q$aGFzaC1kZS10ZXN0LWFkbWluLTEyMzQ1Njc4';
|
||||
|
||||
const account = (overrides: Partial<ExistingAccount> = {}): ExistingAccount => ({
|
||||
id: 'u-1',
|
||||
role: 'ADMIN',
|
||||
isActive: true,
|
||||
lastLoginAt: null,
|
||||
passwordHash: '$argon2id$v=19$m=65536,t=3,p=4$aleatoire$inutilisable',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('decideAdminBootstrap', () => {
|
||||
it('cree le compte absent, avec le hash fourni', () => {
|
||||
expect(decideAdminBootstrap(null, { passwordHash: HASH, resetPassword: false })).toEqual({
|
||||
create: true,
|
||||
promote: false,
|
||||
activate: false,
|
||||
applyPassword: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("applique le hash a un administrateur d'amorcage qui ne s'est jamais connecte", () => {
|
||||
// Cas reproduit : migration passee sans hash, compte inutilisable.
|
||||
const decision = decideAdminBootstrap(account(), { passwordHash: HASH, resetPassword: false });
|
||||
expect(decision.applyPassword).toBe(true);
|
||||
});
|
||||
|
||||
it("n'ecrase jamais le mot de passe d'un administrateur deja connecte", () => {
|
||||
const decision = decideAdminBootstrap(account({ lastLoginAt: new Date() }), {
|
||||
passwordHash: HASH,
|
||||
resetPassword: false,
|
||||
});
|
||||
expect(decision).toEqual({
|
||||
create: false,
|
||||
promote: false,
|
||||
activate: false,
|
||||
applyPassword: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reinitialise le mot de passe sur demande explicite', () => {
|
||||
const decision = decideAdminBootstrap(account({ lastLoginAt: new Date() }), {
|
||||
passwordHash: HASH,
|
||||
resetPassword: true,
|
||||
});
|
||||
expect(decision.applyPassword).toBe(true);
|
||||
});
|
||||
|
||||
it('est idempotent : un hash deja applique ne declenche rien', () => {
|
||||
const decision = decideAdminBootstrap(account({ passwordHash: HASH }), {
|
||||
passwordHash: HASH,
|
||||
resetPassword: true,
|
||||
});
|
||||
expect(decision).toEqual({
|
||||
create: false,
|
||||
promote: false,
|
||||
activate: false,
|
||||
applyPassword: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('promeut et reactive un compte existant, sans hash', () => {
|
||||
const decision = decideAdminBootstrap(account({ role: 'USER', isActive: false }), {
|
||||
resetPassword: false,
|
||||
});
|
||||
expect(decision).toEqual({
|
||||
create: false,
|
||||
promote: true,
|
||||
activate: true,
|
||||
applyPassword: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readAdminBootstrapConfig', () => {
|
||||
const reader = (values: Record<string, string>) => (key: string) => values[key];
|
||||
|
||||
it('ne fait rien sans BOOTSTRAP_ADMIN_EMAIL', () => {
|
||||
expect(readAdminBootstrapConfig(reader({}))).toBeNull();
|
||||
});
|
||||
|
||||
it('refuse un mot de passe en clair a la place du hash', () => {
|
||||
const logger = { error: jest.fn() };
|
||||
const config = readAdminBootstrapConfig(
|
||||
reader({
|
||||
BOOTSTRAP_ADMIN_EMAIL: 'Admin@Xpeditis.com',
|
||||
BOOTSTRAP_ADMIN_PASSWORD_HASH: 'Password123!',
|
||||
}),
|
||||
logger
|
||||
);
|
||||
expect(config?.email).toBe('admin@xpeditis.com');
|
||||
expect(config?.passwordHash).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lit le drapeau de reinitialisation', () => {
|
||||
const config = readAdminBootstrapConfig(
|
||||
reader({
|
||||
BOOTSTRAP_ADMIN_EMAIL: 'admin@xpeditis.com',
|
||||
BOOTSTRAP_ADMIN_PASSWORD_HASH: HASH,
|
||||
BOOTSTRAP_ADMIN_RESET_PASSWORD: 'TRUE',
|
||||
})
|
||||
);
|
||||
expect(config).toMatchObject({ passwordHash: HASH, resetPassword: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminBootstrapService', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
function build(settings: Record<string, string>, existing: Record<string, unknown> | null) {
|
||||
const executed: Array<{ sql: string; params: unknown[] }> = [];
|
||||
const manager = {
|
||||
query: jest.fn(async (sql: string, params: unknown[] = []) => {
|
||||
executed.push({ sql, params });
|
||||
if (sql.includes('FROM "users" WHERE "email"')) return existing ? [existing] : [];
|
||||
if (sql.includes('INSERT INTO "organizations"')) return [{ id: 'org-1' }];
|
||||
return [];
|
||||
}),
|
||||
} as unknown as EntityManager;
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (work: (m: EntityManager) => Promise<void>) => work(manager)),
|
||||
query: jest.fn(async () => [{ n: 1 }]),
|
||||
} as unknown as DataSource;
|
||||
const config = { get: jest.fn((key: string) => settings[key]) };
|
||||
const service = new AdminBootstrapService(config as never, dataSource);
|
||||
return { service, executed, dataSource };
|
||||
}
|
||||
|
||||
it("rend exploitable l'administrateur d'amorcage cree sans mot de passe", async () => {
|
||||
const { service, executed } = build(
|
||||
{ BOOTSTRAP_ADMIN_EMAIL: 'admin@xpeditis.com', BOOTSTRAP_ADMIN_PASSWORD_HASH: HASH },
|
||||
{ id: 'u-1', role: 'ADMIN', is_active: true, last_login_at: null, password_hash: 'aleatoire' }
|
||||
);
|
||||
|
||||
await service.run();
|
||||
|
||||
const update = executed.find(q => q.sql.includes('UPDATE "users"'));
|
||||
expect(update?.params).toEqual(['u-1', true, HASH]);
|
||||
});
|
||||
|
||||
it("cree l'administrateur et son organisation sur une base neuve", async () => {
|
||||
const { service, executed } = build(
|
||||
{ BOOTSTRAP_ADMIN_EMAIL: 'admin@xpeditis.com', BOOTSTRAP_ADMIN_PASSWORD_HASH: HASH },
|
||||
null
|
||||
);
|
||||
|
||||
await service.run();
|
||||
|
||||
const insert = executed.find(q => q.sql.includes('INSERT INTO "users"'));
|
||||
expect(insert?.params).toEqual(['org-1', 'admin@xpeditis.com', HASH, 'Admin', 'Xpeditis']);
|
||||
});
|
||||
|
||||
it('serialise les replicas qui demarrent ensemble (verrou avant la lecture du compte)', async () => {
|
||||
const { service, executed } = build({ BOOTSTRAP_ADMIN_EMAIL: 'admin@xpeditis.com' }, null);
|
||||
|
||||
await service.run();
|
||||
|
||||
expect(executed[0].sql).toContain('pg_advisory_xact_lock');
|
||||
expect(executed[1].sql).toContain('FROM "users" WHERE "email"');
|
||||
});
|
||||
|
||||
it("ne bloque jamais le demarrage de l'API", async () => {
|
||||
const { service, dataSource } = build({ BOOTSTRAP_ADMIN_EMAIL: 'admin@xpeditis.com' }, null);
|
||||
(dataSource.transaction as jest.Mock).mockRejectedValueOnce(new Error('base indisponible'));
|
||||
|
||||
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('ne touche pas la base sans configuration', async () => {
|
||||
const { service, dataSource } = build({}, null);
|
||||
|
||||
await service.run();
|
||||
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Amorçage de l'administrateur, à CHAQUE lancement du backend.
|
||||
*
|
||||
* POURQUOI CE N'EST PLUS (SEULEMENT) UNE MIGRATION
|
||||
* ------------------------------------------------
|
||||
* La migration 1756000000001-BootstrapAdminFromEnv ne s'exécute qu'une fois.
|
||||
* Lancé sur une base neuve avec NODE_ENV=production, le backend obtenait :
|
||||
* - un administrateur créé SANS mot de passe utilisable (sans
|
||||
* BOOTSTRAP_ADMIN_PASSWORD_HASH), récupérable seulement par l'email
|
||||
* « mot de passe oublié », donc dépendant du SMTP ;
|
||||
* - aucune correction possible par la configuration : ajouter le hash puis
|
||||
* relancer ne changeait rien, la migration étant déjà enregistrée ;
|
||||
* - aucune création si un ADMIN actif existait déjà, même inutilisable.
|
||||
* Reproduit sur une base vide : connexion refusée (401) pour tous les comptes.
|
||||
*
|
||||
* CE QUE FAIT CE SERVICE
|
||||
* ----------------------
|
||||
* Si BOOTSTRAP_ADMIN_EMAIL est renseigné, le compte est garanti ADMIN et actif,
|
||||
* et créé s'il n'existe pas. BOOTSTRAP_ADMIN_PASSWORD_HASH est appliqué :
|
||||
* - à un compte qui ne s'est encore jamais connecté (compte d'amorçage) ;
|
||||
* - ou à tout compte si BOOTSTRAP_ADMIN_RESET_PASSWORD=true (à retirer
|
||||
* ensuite : tant qu'il reste, un mot de passe changé depuis l'interface
|
||||
* serait remplacé au lancement suivant).
|
||||
* En dehors de ces deux cas, le mot de passe choisi par l'administrateur n'est
|
||||
* jamais touché.
|
||||
*
|
||||
* Sans configuration exploitable, le service l'annonce dans les journaux avec
|
||||
* la commande de secours (scripts/setup/admin-password.js). Il ne bloque jamais
|
||||
* le démarrage : une API sans administrateur vaut mieux qu'une API arrêtée.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource, EntityManager } 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@]+$/;
|
||||
|
||||
const RECOVERY_COMMAND = 'docker exec -it <conteneur-backend> node admin-password.js <email>';
|
||||
|
||||
export interface AdminBootstrapConfig {
|
||||
email: string;
|
||||
passwordHash?: string;
|
||||
resetPassword: boolean;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
organization: {
|
||||
name: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExistingAccount {
|
||||
id: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
lastLoginAt: Date | null;
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
export interface AdminBootstrapDecision {
|
||||
create: boolean;
|
||||
promote: boolean;
|
||||
activate: boolean;
|
||||
applyPassword: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Règle de décision, sans accès à la base.
|
||||
* Idempotente : relancer avec la même configuration ne produit aucun changement.
|
||||
*/
|
||||
export function decideAdminBootstrap(
|
||||
account: ExistingAccount | null,
|
||||
config: Pick<AdminBootstrapConfig, 'passwordHash' | 'resetPassword'>
|
||||
): AdminBootstrapDecision {
|
||||
if (!account) {
|
||||
return { create: true, promote: false, activate: false, applyPassword: !!config.passwordHash };
|
||||
}
|
||||
|
||||
const hashAlreadyApplied = account.passwordHash === config.passwordHash;
|
||||
const applyPassword =
|
||||
!!config.passwordHash &&
|
||||
!hashAlreadyApplied &&
|
||||
(config.resetPassword || account.lastLoginAt === null);
|
||||
|
||||
return {
|
||||
create: false,
|
||||
promote: account.role !== 'ADMIN',
|
||||
activate: !account.isActive,
|
||||
applyPassword,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lit la configuration d'amorçage ; `null` si aucun email n'est fourni. */
|
||||
export function readAdminBootstrapConfig(
|
||||
get: (key: string) => string | undefined,
|
||||
logger?: Pick<Logger, 'error'>
|
||||
): AdminBootstrapConfig | null {
|
||||
const value = (key: string, fallback = '') => (get(key) ?? fallback).toString().trim();
|
||||
|
||||
const email = value('BOOTSTRAP_ADMIN_EMAIL').toLowerCase();
|
||||
if (!email) return null;
|
||||
|
||||
if (!EMAIL_PATTERN.test(email)) {
|
||||
logger?.error(`[amorçage admin] BOOTSTRAP_ADMIN_EMAIL invalide : "${email}" — ignoré.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let passwordHash: string | undefined = value('BOOTSTRAP_ADMIN_PASSWORD_HASH') || undefined;
|
||||
if (passwordHash && !passwordHash.startsWith('$argon2')) {
|
||||
// Jamais de mot de passe en clair : il serait resté dans l'environnement.
|
||||
logger?.error(
|
||||
'[amorçage admin] BOOTSTRAP_ADMIN_PASSWORD_HASH doit être un hash Argon2 (« $argon2… »), ' +
|
||||
'jamais un mot de passe en clair — ignoré. Générez-le avec scripts/setup/generate-admin-hash.js.'
|
||||
);
|
||||
passwordHash = undefined;
|
||||
}
|
||||
|
||||
const country = value('BOOTSTRAP_ADMIN_ORG_COUNTRY', 'FR').toUpperCase();
|
||||
|
||||
return {
|
||||
email,
|
||||
passwordHash,
|
||||
resetPassword: value('BOOTSTRAP_ADMIN_RESET_PASSWORD').toLowerCase() === 'true',
|
||||
firstName: value('BOOTSTRAP_ADMIN_FIRST_NAME', 'Admin') || 'Admin',
|
||||
lastName: value('BOOTSTRAP_ADMIN_LAST_NAME', 'Xpeditis') || 'Xpeditis',
|
||||
organization: {
|
||||
name: value('BOOTSTRAP_ADMIN_ORG_NAME', 'Xpeditis') || 'Xpeditis',
|
||||
street: value('BOOTSTRAP_ADMIN_ORG_STREET', 'A completer') || 'A completer',
|
||||
city: value('BOOTSTRAP_ADMIN_ORG_CITY', 'A completer') || 'A completer',
|
||||
postalCode: value('BOOTSTRAP_ADMIN_ORG_POSTAL_CODE', '00000') || '00000',
|
||||
country: /^[A-Z]{2}$/.test(country) ? country : 'FR',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminBootstrapService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(AdminBootstrapService.name);
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly dataSource: DataSource
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
await this.run();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(
|
||||
`[amorçage admin] Échec : ${message}. Commande de secours : ${RECOVERY_COMMAND}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const config = readAdminBootstrapConfig(
|
||||
key => this.configService.get<string>(key),
|
||||
this.logger
|
||||
);
|
||||
|
||||
if (config) {
|
||||
await this.dataSource.transaction(manager => this.applyConfig(manager, config));
|
||||
}
|
||||
|
||||
await this.warnIfNoActiveAdmin();
|
||||
}
|
||||
|
||||
private async applyConfig(manager: EntityManager, config: AdminBootstrapConfig): Promise<void> {
|
||||
// En production, 2 a 4 replicas demarrent en meme temps et executent tous
|
||||
// cet amorcage. Sans verrou, chacun verrait le compte absent et tenterait
|
||||
// de le creer : le verrou de transaction les fait passer un par un.
|
||||
await manager.query(`SELECT pg_advisory_xact_lock(hashtext('xpeditis:admin_bootstrap'))`);
|
||||
|
||||
const rows: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
last_login_at: Date | null;
|
||||
password_hash: string;
|
||||
}> = await manager.query(
|
||||
`SELECT "id", "role", "is_active", "last_login_at", "password_hash" FROM "users" WHERE "email" = $1`,
|
||||
[config.email]
|
||||
);
|
||||
|
||||
const account: ExistingAccount | null = rows[0]
|
||||
? {
|
||||
id: rows[0].id,
|
||||
role: rows[0].role,
|
||||
isActive: rows[0].is_active,
|
||||
lastLoginAt: rows[0].last_login_at,
|
||||
passwordHash: rows[0].password_hash,
|
||||
}
|
||||
: null;
|
||||
|
||||
const decision = decideAdminBootstrap(account, config);
|
||||
|
||||
if (decision.create) {
|
||||
await this.createAdmin(manager, config);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!decision.promote && !decision.activate && !decision.applyPassword) {
|
||||
if (config.resetPassword) {
|
||||
this.logger.warn(
|
||||
'[amorçage admin] BOOTSTRAP_ADMIN_RESET_PASSWORD=true est toujours défini : retirez-le, ' +
|
||||
'sinon un mot de passe changé depuis l’interface sera remplacé au prochain lancement.'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await manager.query(
|
||||
`UPDATE "users"
|
||||
SET "role" = 'ADMIN',
|
||||
"is_active" = true,
|
||||
"password_hash" = CASE WHEN $2::boolean THEN $3 ELSE "password_hash" END,
|
||||
"updated_at" = NOW()
|
||||
WHERE "id" = $1`,
|
||||
[account!.id, decision.applyPassword, config.passwordHash ?? '']
|
||||
);
|
||||
|
||||
const changes = [
|
||||
decision.promote ? 'promu ADMIN' : '',
|
||||
decision.activate ? 'réactivé' : '',
|
||||
decision.applyPassword ? 'mot de passe défini depuis BOOTSTRAP_ADMIN_PASSWORD_HASH' : '',
|
||||
].filter(Boolean);
|
||||
this.logger.log(`[amorçage admin] ${config.email} : ${changes.join(', ')}.`);
|
||||
|
||||
if (decision.applyPassword && config.resetPassword) {
|
||||
this.logger.warn(
|
||||
'[amorçage admin] Mot de passe réinitialisé. Retirez maintenant BOOTSTRAP_ADMIN_RESET_PASSWORD ' +
|
||||
'et BOOTSTRAP_ADMIN_PASSWORD_HASH de l’environnement.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async createAdmin(manager: EntityManager, config: AdminBootstrapConfig): Promise<void> {
|
||||
// users.organization_id est NOT NULL : l'organisation doit exister d'abord.
|
||||
const organization: Array<{ id: string }> = await manager.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"`,
|
||||
[
|
||||
config.organization.name,
|
||||
config.organization.street,
|
||||
config.organization.city,
|
||||
config.organization.postalCode,
|
||||
config.organization.country,
|
||||
]
|
||||
);
|
||||
|
||||
// Sans hash fourni : secret aléatoire immédiatement perdu (aucune connexion
|
||||
// possible tant qu'un mot de passe n'est pas défini).
|
||||
const passwordHash =
|
||||
config.passwordHash ??
|
||||
(await argon2.hash(crypto.randomBytes(48).toString('hex'), ARGON2_OPTIONS));
|
||||
|
||||
await manager.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)`,
|
||||
[organization[0].id, config.email, passwordHash, config.firstName, config.lastName]
|
||||
);
|
||||
|
||||
if (config.passwordHash) {
|
||||
this.logger.log(
|
||||
`[amorçage admin] Administrateur ${config.email} créé : connexion possible avec le mot de passe correspondant au hash fourni.`
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`[amorçage admin] Administrateur ${config.email} créé SANS mot de passe utilisable. ` +
|
||||
`Définissez-le avec « mot de passe oublié » (SMTP requis) ou, sans SMTP : ${RECOVERY_COMMAND}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Signale une plateforme sans administrateur actif, avec la marche à suivre. */
|
||||
private async warnIfNoActiveAdmin(): Promise<void> {
|
||||
const result: Array<{ n: number }> = await this.dataSource.query(
|
||||
`SELECT count(*)::int AS n FROM "users" WHERE "role" = 'ADMIN' AND "is_active" = true`
|
||||
);
|
||||
if ((result[0]?.n ?? 0) === 0) {
|
||||
this.logger.error(
|
||||
'[amorçage admin] Aucun administrateur actif. Renseignez BOOTSTRAP_ADMIN_EMAIL ' +
|
||||
`(et BOOTSTRAP_ADMIN_PASSWORD_HASH) puis relancez, ou exécutez : ${RECOVERY_COMMAND}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -77,18 +77,17 @@ data:
|
||||
LOG_EXPORTER_URL: "http://xpeditis-log-exporter:3200"
|
||||
|
||||
# --- Premier administrateur ------------------------------------------------
|
||||
# Lu par la migration 1756000000001-BootstrapAdminFromEnv, qui remplace le
|
||||
# compte de demonstration admin@xpeditis.com / Password123!.
|
||||
# Lu a CHAQUE demarrage des pods par AdminBootstrapService (et, sur une base
|
||||
# neuve, par la migration 1756000000001-BootstrapAdminFromEnv). Le compte est
|
||||
# garanti ADMIN et actif, et cree s'il n'existe pas ; les replicas qui
|
||||
# demarrent ensemble sont serialises par un verrou PostgreSQL.
|
||||
#
|
||||
# Renseigne SEUL (sans BOOTSTRAP_ADMIN_PASSWORD_HASH dans le Secret), il cree
|
||||
# un compte ADMIN actif SANS mot de passe utilisable : vous definissez le
|
||||
# votre via « mot de passe oublie ». Aucun secret n'existe alors nulle part.
|
||||
# Mot de passe : BOOTSTRAP_ADMIN_PASSWORD_HASH dans le Secret (recommande au
|
||||
# premier deploiement). Sans hash, le compte n'a PAS de mot de passe
|
||||
# utilisable : « mot de passe oublie » (SMTP requis) ou, sans SMTP :
|
||||
# kubectl -n xpeditis-prod exec -it deploy/xpeditis-backend -- node admin-password.js ops@xpeditis.com
|
||||
#
|
||||
# La migration ne fait rien s'il existe deja un administrateur actif : elle ne
|
||||
# peut donc pas en creer un second lors d'un deploiement ulterieur.
|
||||
#
|
||||
# Cette adresse doit etre RELEVABLE : c'est par elle que passe le lien de
|
||||
# definition du mot de passe.
|
||||
# Cette adresse doit etre RELEVABLE.
|
||||
BOOTSTRAP_ADMIN_EMAIL: "ops@xpeditis.com"
|
||||
BOOTSTRAP_ADMIN_FIRST_NAME: "Admin"
|
||||
BOOTSTRAP_ADMIN_LAST_NAME: "Xpeditis"
|
||||
|
||||
@ -75,22 +75,23 @@ stringData:
|
||||
STRIPE_PLATINIUM_MONTHLY_PRICE_ID: "REMPLACER_price_"
|
||||
STRIPE_PLATINIUM_YEARLY_PRICE_ID: "REMPLACER_price_"
|
||||
|
||||
# --- Premier administrateur (FACULTATIF) -----------------------------------
|
||||
# LAISSEZ VIDE dans le cas nominal.
|
||||
# --- Mot de passe du premier administrateur --------------------------------
|
||||
# RECOMMANDE au premier deploiement : sans lui, BOOTSTRAP_ADMIN_EMAIL (voir
|
||||
# ConfigMap) est cree sans mot de passe utilisable, et la premiere connexion
|
||||
# depend alors de l'email « mot de passe oublie ».
|
||||
#
|
||||
# Vide + BOOTSTRAP_ADMIN_EMAIL renseigne dans le ConfigMap : le compte ADMIN
|
||||
# est cree sans mot de passe utilisable, et vous definissez le votre via
|
||||
# « mot de passe oublie ». Aucun secret n'existe nulle part -- rien a faire
|
||||
# fuiter, et la reception du courriel prouve au passage que SMTP fonctionne.
|
||||
#
|
||||
# Ne renseignez ce champ que si vous devez pouvoir vous connecter AVANT que
|
||||
# l'envoi de courriels ne soit operationnel. Dans ce cas :
|
||||
# cd apps/backend && node scripts/setup/generate-admin-hash.js
|
||||
# Le hash reste attaquable hors ligne : changez le mot de passe des la
|
||||
# premiere connexion, puis RETIREZ cette valeur et reappliquez le Secret.
|
||||
#
|
||||
# Un mot de passe en clair place ici fait echouer la migration : la valeur
|
||||
# doit commencer par "$argon2".
|
||||
# Collez le hash tel quel : en YAML Kubernetes, les $ ne se doublent PAS
|
||||
# (contrairement a Docker Compose / Portainer, ou il faut ecrire $$).
|
||||
#
|
||||
# Applique au demarrage si le compte ne s'est jamais connecte, ou sur demande
|
||||
# avec BOOTSTRAP_ADMIN_RESET_PASSWORD: "true" dans le ConfigMap. Le mot de
|
||||
# passe d'un administrateur deja connecte n'est jamais ecrase.
|
||||
#
|
||||
# Le hash reste attaquable hors ligne : apres la premiere connexion, changez
|
||||
# le mot de passe, RETIREZ cette valeur et reappliquez le Secret.
|
||||
# La valeur doit commencer par "$argon2" (un mot de passe en clair est refuse).
|
||||
BOOTSTRAP_ADMIN_PASSWORD_HASH: ""
|
||||
|
||||
# --- Pappers (registre SIRET) ----------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user