From 80afe2fa2fc5f8939676041c3722262ccaa0dead Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 28 Nov 2025 16:29:33 +0100 Subject: [PATCH] feat(backend): service de configuration avec cache et encryption (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémentation du service de configuration dynamique pour le déploiement on-premise de l'application. Nouveaux fichiers : - entities/configuration.entity.ts : Entité TypeORM - modules/config/config.service.ts : Service avec cache et encryption - modules/config/config.module.ts : Module NestJS - modules/config/index.ts : Export centralisé Fonctionnalités : ✅ Cache en mémoire au démarrage (16 configurations) ✅ Chiffrement AES-256-CBC pour valeurs sensibles ✅ Conversion automatique de types (string/number/boolean/json) ✅ Méthodes get/set avec traçabilité ✅ Récupération par catégorie (email/app/security) ✅ Masquage automatique des mots de passe ✅ Support setup wizard (isSetupCompleted) Sécurité : - Clé de chiffrement depuis CONFIG_ENCRYPTION_KEY - Format iv:encrypted pour AES-256-CBC - Mots de passe masqués dans les API Intégration : - AppConfigModule ajouté à app.module.ts - Service global exporté pour utilisation dans toute l'app - Chargement automatique au démarrage (OnModuleInit) Tests : ✅ Build Docker réussi ✅ 16 configurations chargées en cache ✅ Service démarré sans erreur Ref: #5 --- backend/src/app.module.ts | 2 + backend/src/entities/configuration.entity.ts | 39 +++ backend/src/modules/config/config.module.ts | 12 + backend/src/modules/config/config.service.ts | 274 +++++++++++++++++++ backend/src/modules/config/index.ts | 3 + 5 files changed, 330 insertions(+) create mode 100644 backend/src/entities/configuration.entity.ts create mode 100644 backend/src/modules/config/config.module.ts create mode 100644 backend/src/modules/config/config.service.ts create mode 100644 backend/src/modules/config/index.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 40514a5..18b36a4 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -14,6 +14,7 @@ import { AuthModule } from './routes/auth/auth.module'; import { SentryGlobalFilter } from '@sentry/nestjs/setup'; import { AllExceptionsFilter } from './common/filters/all_exceptions.filters'; import { EnfantsModule } from './routes/enfants/enfants.module'; +import { AppConfigModule } from './modules/config/config.module'; @Module({ imports: [ @@ -49,6 +50,7 @@ import { EnfantsModule } from './routes/enfants/enfants.module'; ParentsModule, EnfantsModule, AuthModule, + AppConfigModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/entities/configuration.entity.ts b/backend/src/entities/configuration.entity.ts new file mode 100644 index 0000000..d2d30bb --- /dev/null +++ b/backend/src/entities/configuration.entity.ts @@ -0,0 +1,39 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Users } from './users.entity'; + +@Entity('configuration') +export class Configuration { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100, unique: true, nullable: false }) + cle: string; + + @Column({ type: 'text', nullable: true }) + valeur: string | null; + + @Column({ type: 'varchar', length: 50, nullable: false }) + type: 'string' | 'number' | 'boolean' | 'json' | 'encrypted'; + + @Column({ type: 'varchar', length: 50, nullable: true }) + categorie: 'email' | 'app' | 'security' | null; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @UpdateDateColumn({ name: 'modifie_le' }) + modifieLe: Date; + + @ManyToOne(() => Users, { nullable: true }) + @JoinColumn({ name: 'modifie_par' }) + modifiePar: Users | null; +} + diff --git a/backend/src/modules/config/config.module.ts b/backend/src/modules/config/config.module.ts new file mode 100644 index 0000000..5b03c3d --- /dev/null +++ b/backend/src/modules/config/config.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Configuration } from '../../entities/configuration.entity'; +import { AppConfigService } from './config.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Configuration])], + providers: [AppConfigService], + exports: [AppConfigService], +}) +export class AppConfigModule {} + diff --git a/backend/src/modules/config/config.service.ts b/backend/src/modules/config/config.service.ts new file mode 100644 index 0000000..2119f87 --- /dev/null +++ b/backend/src/modules/config/config.service.ts @@ -0,0 +1,274 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Configuration } from '../../entities/configuration.entity'; +import * as crypto from 'crypto'; + +@Injectable() +export class AppConfigService implements OnModuleInit { + private readonly logger = new Logger(AppConfigService.name); + private cache: Map = new Map(); + private readonly ENCRYPTION_KEY: string; + private readonly ENCRYPTION_ALGORITHM = 'aes-256-cbc'; + private readonly IV_LENGTH = 16; + + constructor( + @InjectRepository(Configuration) + private configRepo: Repository, + ) { + // Clé de chiffrement depuis les variables d'environnement + // En production, cette clé doit être générée de manière sécurisée + this.ENCRYPTION_KEY = + process.env.CONFIG_ENCRYPTION_KEY || + crypto.randomBytes(32).toString('hex'); + + if (!process.env.CONFIG_ENCRYPTION_KEY) { + this.logger.warn( + '⚠️ CONFIG_ENCRYPTION_KEY non définie. Utilisation d\'une clé temporaire (NON RECOMMANDÉ EN PRODUCTION)', + ); + } + } + + /** + * Chargement du cache au démarrage de l'application + */ + async onModuleInit() { + await this.loadCache(); + } + + /** + * Chargement de toutes les configurations en cache + */ + async loadCache(): Promise { + try { + const configs = await this.configRepo.find(); + this.logger.log(`📦 Chargement de ${configs.length} configurations en cache`); + + for (const config of configs) { + let value = config.valeur; + + // Déchiffrement si nécessaire + if (config.type === 'encrypted' && value) { + try { + value = this.decrypt(value); + } catch (error) { + this.logger.error( + `❌ Erreur de déchiffrement pour la clé '${config.cle}'`, + error, + ); + value = null; + } + } + + // Conversion de type + const convertedValue = this.convertType(value, config.type); + this.cache.set(config.cle, convertedValue); + } + + this.logger.log('✅ Cache de configuration chargé avec succès'); + } catch (error) { + this.logger.error('❌ Erreur lors du chargement du cache', error); + throw error; + } + } + + /** + * Récupération d'une valeur de configuration + * @param key Clé de configuration + * @param defaultValue Valeur par défaut si la clé n'existe pas + * @returns Valeur de configuration + */ + get(key: string, defaultValue?: T): T { + if (this.cache.has(key)) { + return this.cache.get(key) as T; + } + + if (defaultValue !== undefined) { + return defaultValue; + } + + this.logger.warn(`⚠️ Configuration '${key}' non trouvée et aucune valeur par défaut fournie`); + return undefined as T; + } + + /** + * Mise à jour d'une valeur de configuration + * @param key Clé de configuration + * @param value Nouvelle valeur + * @param userId ID de l'utilisateur qui modifie + */ + async set(key: string, value: any, userId?: string): Promise { + const config = await this.configRepo.findOne({ where: { cle: key } }); + + if (!config) { + throw new Error(`Configuration '${key}' non trouvée`); + } + + let valueToStore = value !== null && value !== undefined ? String(value) : null; + + // Chiffrement si nécessaire + if (config.type === 'encrypted' && valueToStore) { + valueToStore = this.encrypt(valueToStore); + } + + config.valeur = valueToStore; + config.modifieLe = new Date(); + + if (userId) { + config.modifiePar = { id: userId } as any; + } + + await this.configRepo.save(config); + + // Mise à jour du cache (avec la valeur déchiffrée) + this.cache.set(key, value); + + this.logger.log(`✅ Configuration '${key}' mise à jour`); + } + + /** + * Récupération de toutes les configurations par catégorie + * @param category Catégorie de configuration + * @returns Objet clé/valeur des configurations + */ + async getByCategory(category: string): Promise> { + const configs = await this.configRepo.find({ + where: { categorie: category as any }, + }); + + const result: Record = {}; + + for (const config of configs) { + let value = config.valeur; + + // Masquer les mots de passe + if (config.type === 'encrypted') { + value = value ? '***********' : null; + } else { + value = this.convertType(value, config.type); + } + + result[config.cle] = { + value, + type: config.type, + description: config.description, + }; + } + + return result; + } + + /** + * Récupération de toutes les configurations (pour l'admin) + * @returns Liste de toutes les configurations + */ + async getAll(): Promise { + const configs = await this.configRepo.find({ + order: { categorie: 'ASC', cle: 'ASC' }, + }); + + // Masquer les valeurs chiffrées + return configs.map((config) => ({ + ...config, + valeur: config.type === 'encrypted' && config.valeur ? '***********' : config.valeur, + })); + } + + /** + * Test de la configuration SMTP + * @returns true si la connexion SMTP fonctionne + */ + async testSmtpConnection(): Promise { + // TODO: Implémenter le test SMTP avec Nodemailer + // Pour l'instant, on retourne true + this.logger.log('🧪 Test de connexion SMTP (à implémenter)'); + return true; + } + + /** + * Vérification si la configuration initiale est terminée + * @returns true si la configuration est terminée + */ + isSetupCompleted(): boolean { + return this.get('setup_completed', false); + } + + /** + * Marquer la configuration initiale comme terminée + * @param userId ID de l'utilisateur qui termine la configuration + */ + async markSetupCompleted(userId: string): Promise { + await this.set('setup_completed', 'true', userId); + this.logger.log('✅ Configuration initiale marquée comme terminée'); + } + + /** + * Conversion de type selon le type de configuration + * @param value Valeur à convertir + * @param type Type cible + * @returns Valeur convertie + */ + private convertType(value: string | null, type: string): any { + if (value === null || value === undefined) { + return null; + } + + switch (type) { + case 'number': + return parseFloat(value); + case 'boolean': + return value === 'true' || value === '1'; + case 'json': + try { + return JSON.parse(value); + } catch { + return null; + } + case 'string': + case 'encrypted': + default: + return value; + } + } + + /** + * Chiffrement AES-256-CBC + * @param text Texte à chiffrer + * @returns Texte chiffré (format: iv:encrypted) + */ + private encrypt(text: string): string { + const iv = crypto.randomBytes(this.IV_LENGTH); + const key = Buffer.from(this.ENCRYPTION_KEY, 'hex'); + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv); + + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + // Format: iv:encrypted + return `${iv.toString('hex')}:${encrypted}`; + } + + /** + * Déchiffrement AES-256-CBC + * @param encryptedText Texte chiffré (format: iv:encrypted) + * @returns Texte déchiffré + */ + private decrypt(encryptedText: string): string { + const parts = encryptedText.split(':'); + if (parts.length !== 2) { + throw new Error('Format de chiffrement invalide'); + } + + const iv = Buffer.from(parts[0], 'hex'); + const encrypted = parts[1]; + const key = Buffer.from(this.ENCRYPTION_KEY, 'hex'); + + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv); + + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } +} + diff --git a/backend/src/modules/config/index.ts b/backend/src/modules/config/index.ts new file mode 100644 index 0000000..9f4ac4b --- /dev/null +++ b/backend/src/modules/config/index.ts @@ -0,0 +1,3 @@ +export * from './config.module'; +export * from './config.service'; + -- 2.47.2