Merge pull request '[Backend] Service de configuration avec cache et encryption' (#66) from feature/5-service-configuration into master

Merge pull request #66: [Backend] Service de configuration avec cache et encryption

Implémentation du service de configuration dynamique avec cache en mémoire et chiffrement AES-256-CBC.

Closes #5
This commit is contained in:
MARTIN Julien 2025-11-28 15:33:20 +00:00
commit ec485b5a3e
5 changed files with 330 additions and 0 deletions

View File

@ -14,6 +14,7 @@ import { AuthModule } from './routes/auth/auth.module';
import { SentryGlobalFilter } from '@sentry/nestjs/setup'; import { SentryGlobalFilter } from '@sentry/nestjs/setup';
import { AllExceptionsFilter } from './common/filters/all_exceptions.filters'; import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
import { EnfantsModule } from './routes/enfants/enfants.module'; import { EnfantsModule } from './routes/enfants/enfants.module';
import { AppConfigModule } from './modules/config/config.module';
@Module({ @Module({
imports: [ imports: [
@ -49,6 +50,7 @@ import { EnfantsModule } from './routes/enfants/enfants.module';
ParentsModule, ParentsModule,
EnfantsModule, EnfantsModule,
AuthModule, AuthModule,
AppConfigModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

View File

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

View File

@ -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 {}

View File

@ -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<string, any> = 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<Configuration>,
) {
// 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<void> {
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<T = any>(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<void> {
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<Record<string, any>> {
const configs = await this.configRepo.find({
where: { categorie: category as any },
});
const result: Record<string, any> = {};
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<Configuration[]> {
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<boolean> {
// 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<boolean>('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<void> {
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;
}
}

View File

@ -0,0 +1,3 @@
export * from './config.module';
export * from './config.service';