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:
commit
ec485b5a3e
@ -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: [
|
||||||
|
|||||||
39
backend/src/entities/configuration.entity.ts
Normal file
39
backend/src/entities/configuration.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
12
backend/src/modules/config/config.module.ts
Normal file
12
backend/src/modules/config/config.module.ts
Normal 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 {}
|
||||||
|
|
||||||
274
backend/src/modules/config/config.service.ts
Normal file
274
backend/src/modules/config/config.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
3
backend/src/modules/config/index.ts
Normal file
3
backend/src/modules/config/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './config.module';
|
||||||
|
export * from './config.service';
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user