[Backend] Service de configuration avec cache et encryption #66
@ -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: [
|
||||
|
||||
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