diff --git a/src/app.module.ts b/src/app.module.ts index aa872bb..b809cb4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -30,7 +30,9 @@ import { SubscriptionsModule } from './modules/subscriptions/subscriptions.modul useFactory: (configService: ConfigService) => ({ redis: { host: configService.get('app.redis.host'), - port: configService.get('app.redis.port'), + port: configService.get('app.redis.port'), + password: configService.get('app.redis.password'), + }, }), inject: [ConfigService], @@ -41,7 +43,9 @@ import { SubscriptionsModule } from './modules/subscriptions/subscriptions.modul store: redisStore, host: configService.get('app.redis.host'), port: configService.get('app.redis.port'), + password: configService.get('app.redis.password'), ttl: 600, // 10 minutes default + }), inject: [ConfigService], isGlobal: true, diff --git a/src/common/commonde.module.ts b/src/common/commonde.module.ts new file mode 100644 index 0000000..a193691 --- /dev/null +++ b/src/common/commonde.module.ts @@ -0,0 +1,59 @@ +import { Module } from '@nestjs/common'; +import Redis from 'ioredis'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { RedisCacheService } from './services/cache.redis'; + +/** + * Module pour le challenge OTP + * Gère l'injection de dépendances et la configuration + */ +@Module({ + imports: [ + + ], + controllers: [], + providers: [ + + { + provide: 'REDIS_CLIENT', + useFactory: (configService: ConfigService) => { + const redisConfig = { + host: configService.get('REDIS_HOST', 'localhost'), + port: configService.get('REDIS_PORT', 6379), + password: configService.get('REDIS_PASSWORD'), // ⚠️ Important + db: configService.get('REDIS_DB', 0), + keyPrefix: configService.get('REDIS_KEY_PREFIX', 'app:'), + retryStrategy: (times) => { + const delay = Math.min(times * 50, 2000); + return delay; + }, + maxRetriesPerRequest: 3, + enableOfflineQueue: false, + lazyConnect: false, // Connexion immédiate + }; + + const redis = new Redis(redisConfig); + + // Gestion des événements de connexion + redis.on('connect', () => { + console.log('✅ Redis connected successfully'); + }); + + redis.on('error', (err) => { + console.error('❌ Redis connection error:', err.message); + }); + + redis.on('ready', () => { + console.log('✅ Redis is ready'); + }); + + return redis; + }, + inject: [ConfigService], + }, + + RedisCacheService, + ], + exports: [RedisCacheService], +}) +export class CommonModule {} \ No newline at end of file diff --git a/src/common/services/cache.redis.ts b/src/common/services/cache.redis.ts new file mode 100644 index 0000000..f0982e9 --- /dev/null +++ b/src/common/services/cache.redis.ts @@ -0,0 +1,252 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { Redis } from 'ioredis'; + +export interface CacheOptions { + ttl?: number; // Time to live en secondes + prefix?: string; // Préfixe pour les clés +} + +@Injectable() +export class RedisCacheService { + private readonly logger = new Logger(RedisCacheService.name); + private readonly DEFAULT_TTL = 300; // 5 minutes + + constructor(@Inject('REDIS_CLIENT') private readonly redis: Redis) {} + + /** + * Sauvegarder une valeur dans le cache + */ + async set( + key: string, + value: T, + options?: CacheOptions + ): Promise { + try { + const fullKey = this.buildKey(key, options?.prefix); + const serializedValue = JSON.stringify(value); + const ttl = options?.ttl || this.DEFAULT_TTL; + + await this.redis.setex(fullKey, ttl, serializedValue); + + this.logger.debug(`Cache set: ${fullKey} (TTL: ${ttl}s)`); + } catch (error) { + this.logger.error(`Failed to set cache for key ${key}:`, error); + throw error; + } + } + + /** + * Récupérer une valeur depuis le cache + */ + async get(key: string, prefix?: string): Promise { + try { + const fullKey = this.buildKey(key, prefix); + const data = await this.redis.get(fullKey); + + if (!data) { + this.logger.debug(`Cache miss: ${fullKey}`); + return null; + } + + this.logger.debug(`Cache hit: ${fullKey}`); + return JSON.parse(data) as T; + } catch (error) { + this.logger.error(`Failed to get cache for key ${key}:`, error); + return null; + } + } + + /** + * Supprimer une valeur du cache + */ + async delete(key: string, prefix?: string): Promise { + try { + const fullKey = this.buildKey(key, prefix); + const result = await this.redis.del(fullKey); + + this.logger.debug(`Cache delete: ${fullKey}`); + return result > 0; + } catch (error) { + this.logger.error(`Failed to delete cache for key ${key}:`, error); + return false; + } + } + + /** + * Vérifier si une clé existe + */ + async exists(key: string, prefix?: string): Promise { + try { + const fullKey = this.buildKey(key, prefix); + const result = await this.redis.exists(fullKey); + return result === 1; + } catch (error) { + this.logger.error(`Failed to check existence for key ${key}:`, error); + return false; + } + } + + /** + * Mettre à jour le TTL d'une clé + */ + async updateTTL(key: string, ttl: number, prefix?: string): Promise { + try { + const fullKey = this.buildKey(key, prefix); + const result = await this.redis.expire(fullKey, ttl); + return result === 1; + } catch (error) { + this.logger.error(`Failed to update TTL for key ${key}:`, error); + return false; + } + } + + /** + * Récupérer le TTL restant d'une clé + */ + async getTTL(key: string, prefix?: string): Promise { + try { + const fullKey = this.buildKey(key, prefix); + return await this.redis.ttl(fullKey); + } catch (error) { + this.logger.error(`Failed to get TTL for key ${key}:`, error); + return -1; + } + } + + /** + * Supprimer toutes les clés avec un préfixe donné + */ + async deleteByPrefix(prefix: string): Promise { + try { + const pattern = `${prefix}*`; + const keys = await this.redis.keys(pattern); + + if (keys.length === 0) { + return 0; + } + + const result = await this.redis.del(...keys); + this.logger.debug(`Deleted ${result} keys with prefix: ${prefix}`); + return result; + } catch (error) { + this.logger.error(`Failed to delete by prefix ${prefix}:`, error); + return 0; + } + } + + /** + * Récupérer plusieurs valeurs en une fois + */ + async mget(keys: string[], prefix?: string): Promise<(T | null)[]> { + try { + const fullKeys = keys.map(key => this.buildKey(key, prefix)); + const values = await this.redis.mget(...fullKeys); + + return values.map(value => { + if (!value) return null; + try { + return JSON.parse(value) as T; + } catch { + return null; + } + }); + } catch (error) { + this.logger.error('Failed to get multiple cache values:', error); + return keys.map(() => null); + } + } + + /** + * Sauvegarder plusieurs valeurs en une fois + */ + async mset( + entries: Array<{ key: string; value: T }>, + options?: CacheOptions + ): Promise { + try { + const pipeline = this.redis.pipeline(); + const ttl = options?.ttl || this.DEFAULT_TTL; + + for (const entry of entries) { + const fullKey = this.buildKey(entry.key, options?.prefix); + const serializedValue = JSON.stringify(entry.value); + pipeline.setex(fullKey, ttl, serializedValue); + } + + await pipeline.exec(); + this.logger.debug(`Batch set ${entries.length} cache entries`); + } catch (error) { + this.logger.error('Failed to set multiple cache values:', error); + throw error; + } + } + + /** + * Incrémenter une valeur numérique + */ + async increment(key: string, prefix?: string, amount: number = 1): Promise { + try { + const fullKey = this.buildKey(key, prefix); + return await this.redis.incrby(fullKey, amount); + } catch (error) { + this.logger.error(`Failed to increment key ${key}:`, error); + throw error; + } + } + + /** + * Obtenir ou définir (get-or-set pattern) + */ + async getOrSet( + key: string, + factory: () => Promise, + options?: CacheOptions + ): Promise { + try { + // Essayer de récupérer depuis le cache + const cached = await this.get(key, options?.prefix); + + if (cached !== null) { + return cached; + } + + // Si pas en cache, exécuter la factory + const value = await factory(); + + // Sauvegarder dans le cache + await this.set(key, value, options); + + return value; + } catch (error) { + this.logger.error(`Failed getOrSet for key ${key}:`, error); + throw error; + } + } + + /** + * Construire la clé complète avec préfixe + */ + private buildKey(key: string, prefix?: string): string { + return prefix ? `${prefix}:${key}` : key; + } + + /** + * Vider tout le cache (ATTENTION: à utiliser avec précaution) + */ + async flushAll(): Promise { + try { + await this.redis.flushdb(); + this.logger.warn('Cache flushed completely'); + } catch (error) { + this.logger.error('Failed to flush cache:', error); + throw error; + } + } + + /** + * Obtenir des informations sur Redis + */ + async info(): Promise { + return await this.redis.info(); + } +} \ No newline at end of file diff --git a/src/config/app.config.ts b/src/config/app.config.ts index e0bfb97..32a438d 100644 --- a/src/config/app.config.ts +++ b/src/config/app.config.ts @@ -12,5 +12,6 @@ export default registerAs('app', () => ({ redis: { host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT?? "6379", 10) || 6379, + password: process.env.REDIS_PASSWORD || undefined, }, })); diff --git a/src/modules/challenge/adaptor/dtos/orange.challenge.dto.ts b/src/modules/challenge/adaptor/dtos/orange.challenge.dto.ts index 7467165..5136a02 100644 --- a/src/modules/challenge/adaptor/dtos/orange.challenge.dto.ts +++ b/src/modules/challenge/adaptor/dtos/orange.challenge.dto.ts @@ -1,29 +1,32 @@ /** * Structure de la requête pour l'API Orange DCB Challenge v2 */ + + export interface OrangeChallengeRequest { - country: string; - method: string; - service: string; - partnerId: string; - identifier: { - type: string; - value: string; - }; - confirmationCode: string; - message: string; - otpLength: number; - senderName: string; + challenge:{ + country: string; + method: string; + service: string; + partnerId: string; + inputs: any[]; + } + } /** * Structure de la réponse de l'API Orange DCB Challenge */ export interface OrangeChallengeResponse { - challengeId?: string; - message?: string; - expiresIn?: number; - sessionId?: string; + challenge: { + method: string, + result: any[], + country: string, + service: string, + partnerId:string, + inputs: [ ] + } + location:string; // "/challenge/v1/challenges/c87d3360-c7bc-488f-86aa-02a537eaf1cc" error?: { code: number | string; message: string; @@ -35,13 +38,20 @@ export interface OrangeChallengeResponse { * Builder pour construire des requêtes Orange Challenge */ export class OrangeChallengeRequestBuilder { - private request: Partial = {}; + private request: OrangeChallengeRequest = { + challenge:{ + country:'', + method: '', + service: '', + partnerId: '', + inputs:[]} + }; /** * Définir le pays */ withCountry(country: string): this { - this.request.country = country; + this.request.challenge.country = country; return this; } @@ -49,7 +59,7 @@ export class OrangeChallengeRequestBuilder { * Définir la méthode d'authentification */ withMethod(method: string): this { - this.request.method = method; + this.request.challenge.method = method; return this; } @@ -57,7 +67,7 @@ export class OrangeChallengeRequestBuilder { * Définir le service */ withService(service: string): this { - this.request.service = service; + this.request.challenge.service = service; return this; } @@ -65,7 +75,7 @@ export class OrangeChallengeRequestBuilder { * Définir l'ID du partenaire */ withPartnerId(partnerId: string): this { - this.request.partnerId = partnerId; + this.request.challenge.partnerId = partnerId; return this; } @@ -73,10 +83,12 @@ export class OrangeChallengeRequestBuilder { * Définir l'identifiant (numéro de téléphone, etc.) */ withIdentifier(type: string, value: string): this { - this.request.identifier = { - type, - value - }; + this.request.challenge.inputs?.push({ + "type": type,//, or “ISE2” + "value": value// or “PDKSUB-XXXXXX” + }, + ) + return this; } @@ -84,23 +96,41 @@ export class OrangeChallengeRequestBuilder { * Définir le code de confirmation (OTP) */ withConfirmationCode(code: string): this { - this.request.confirmationCode = code; + this.request.challenge.inputs?.push( + { + "type": "confirmationCode", + "value": code + }, + ); return this; } /** * Définir le message OTP */ + //todo voir value par defaut withMessage(message: string): this { - this.request.message = message; + this.request.challenge.inputs?.push( + { + "type": "message", + "value": message + }, + ) return this; } /** * Définir la longueur de l'OTP */ + //todo mettre la valeur par defaut withOtpLength(length: number): this { - this.request.otpLength = length; + this.request.challenge.inputs?.push( + { + "type": "otpLength", + "value": length + }, + + ) return this; } @@ -108,7 +138,12 @@ export class OrangeChallengeRequestBuilder { * Définir le nom de l'expéditeur */ withSenderName(senderName: string): this { - this.request.senderName = senderName; + this.request.challenge.inputs?.push( + { + "type": "senderName", + "value": senderName + } + ) return this; } @@ -117,20 +152,20 @@ export class OrangeChallengeRequestBuilder { */ build(): OrangeChallengeRequest { // Validation des champs obligatoires - if (!this.request.country) { + if (!this.request.challenge.country) { throw new Error('Country is required'); } - if (!this.request.method) { + if (!this.request.challenge.method) { throw new Error('Method is required'); } - if (!this.request.service) { + if (!this.request.challenge.service) { throw new Error('Service is required'); } - if (!this.request.partnerId) { + if (!this.request.challenge.partnerId) { throw new Error('Partner ID is required'); } - if (!this.request.identifier) { - throw new Error('Identifier is required'); + if (!this.request.challenge.inputs) { + throw new Error('inputs is required'); } return this.request as OrangeChallengeRequest; @@ -140,7 +175,14 @@ export class OrangeChallengeRequestBuilder { * Réinitialiser le builder */ reset(): this { - this.request = {}; + this.request ={ + challenge:{ + country:'', + method: '', + service: '', + partnerId: '', + inputs:[]} + }; return this; } } \ No newline at end of file diff --git a/src/modules/challenge/adaptor/orange.adaptor.ts b/src/modules/challenge/adaptor/orange.adaptor.ts index 82f981b..da11204 100644 --- a/src/modules/challenge/adaptor/orange.adaptor.ts +++ b/src/modules/challenge/adaptor/orange.adaptor.ts @@ -14,12 +14,16 @@ import { //import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from '../../dtos/otp-challenge-response.dto'; import { OtpChallengeRequestDto } from '../dto/challenge.request.dto'; import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from '../dto/challenge.response.dto'; +import { Logger } from '@nestjs/common'; +import { log } from 'console'; /** * Adaptateur pour l'API Orange DCB v2 * Gère l'authentification OAuth2 et les appels à l'API Challenge */ export class OrangeAdapter { + private readonly logger = new Logger(OrangeAdapter.name); + private axiosInstance: AxiosInstance; private config: OrangeConfig; private accessToken: string | null = null; @@ -58,6 +62,9 @@ export class OrangeAdapter { `${this.config.clientId}:${this.config.clientSecret}` ).toString('base64'); + //this.logger.debug( `request to get acces token , ${this.config.baseUrl}${this.config.tokenEndpoint}`) + + const response = await axios.post( `${this.config.baseUrl}${this.config.tokenEndpoint}`, 'grant_type=client_credentials', @@ -131,13 +138,14 @@ export class OrangeAdapter { orangeResponse: OrangeChallengeResponse, request: OtpChallengeRequestDto ): OtpChallengeResponseDto { + const partsChallengeLocation= orangeResponse.location.split('/'); const response: OtpChallengeResponseDto = { - challengeId: orangeResponse.challengeId || '', + challengeId: partsChallengeLocation[partsChallengeLocation.length - 1], merchantId: request.merchantId, status: this.mapOrangeStatus(orangeResponse), - message: orangeResponse.message, - expiresIn: orangeResponse.expiresIn, - sessionId: orangeResponse.sessionId, + message: orangeResponse.challenge.result+"", + expiresIn: new Date().getTime(), + //sessionId: orangeResponse.sessionId, requiresConfirmation: true, metadata: { provider: 'orange', @@ -167,7 +175,7 @@ export class OrangeAdapter { return OtpChallengeStatusEnum.FAILED; } - if (orangeResponse.challengeId) { + if (orangeResponse.location) { return OtpChallengeStatusEnum.SENT; } @@ -197,10 +205,15 @@ export class OrangeAdapter { try { // Obtenir le token const token = await this.getAccessToken(); + //this.logger.debug(`initiateChallenge --> acces token ${token}`); // Mapper la requête const orangeRequest = this.mapToOrangeRequest(request); + this.logger.debug( + `[request to orange ]: ${JSON.stringify(orangeRequest, null, 2)}`, + ) + // Appeler l'API Orange const response = await this.axiosInstance.post( this.config.challengeEndpoint, diff --git a/src/modules/challenge/otp.challenge.module.ts b/src/modules/challenge/otp.challenge.module.ts index a8705e3..6f9eeb2 100644 --- a/src/modules/challenge/otp.challenge.module.ts +++ b/src/modules/challenge/otp.challenge.module.ts @@ -3,19 +3,21 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { OtpChallengeController } from './otp.challenge.controller'; import { OrangeConfig } from './adaptor/orange.config'; import { OtpChallengeService } from './otp.challenge.service'; +import { CommonModule } from 'src/common/commonde.module'; /** * Module pour le challenge OTP * Gère l'injection de dépendances et la configuration */ @Module({ - imports: [ConfigModule], + imports: [ConfigModule, CommonModule], controllers: [OtpChallengeController], providers: [ { provide: 'ORANGE_CONFIG', useFactory: (configService: ConfigService): OrangeConfig => ({ baseUrl: configService.get('ORANGE_BASE_URL', 'https://webhook.site/69ce9344-f87b-421a-a494-c59eca7c54ce'), + //tokenUrl: configService.get('ORANGE_BASE_URL', 'https://webhook.site/69ce9344-f87b-421a-a494-c59eca7c54ce'), partnerId: configService.get('ORANGE_PARTNER_ID', 'PDKSUB'), clientId: configService.get('ORANGE_CLIENT_ID', 'admin'), clientSecret: configService.get('ORANGE_CLIENT_SECRET', 'admin'), diff --git a/src/modules/challenge/otp.challenge.service.ts b/src/modules/challenge/otp.challenge.service.ts index c6dac3b..b9aaa59 100644 --- a/src/modules/challenge/otp.challenge.service.ts +++ b/src/modules/challenge/otp.challenge.service.ts @@ -1,22 +1,29 @@ -import { Injectable, Inject } from '@nestjs/common'; +import { Injectable, Inject, Logger } from '@nestjs/common'; import type { OrangeConfig } from './adaptor/orange.config'; import { OrangeAdapter } from './adaptor/orange.adaptor'; import { OtpChallengeRequestDto } from './dto/challenge.request.dto'; import { IOtpChallengeService } from './otp.challenge.interface'; import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from './dto/challenge.response.dto'; - +import { RedisCacheService } from 'src/common/services/cache.redis'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; + /** * Service Hub pour gérer les challenges OTP * Utilise l'adaptateur Orange pour communiquer avec l'API Orange DCB */ @Injectable() export class OtpChallengeService implements IOtpChallengeService { + private readonly logger = new Logger(OtpChallengeService.name); + private readonly CACHE_PREFIX = 'otp:challenge'; + private readonly DEFAULT_TTL = 300; // 5 minutes private orangeAdapter: OrangeAdapter; - private challengeCache: Map; + + constructor( + //@Inject(CACHE_MANAGER) private cacheManager: Cache, + @Inject('ORANGE_CONFIG') private readonly orangeConfig: OrangeConfig, + private readonly cacheService: RedisCacheService) { + this.orangeAdapter = new OrangeAdapter(this.orangeConfig); - constructor(@Inject('ORANGE_CONFIG') private readonly orangeConfig: OrangeConfig) { - this.orangeAdapter = new OrangeAdapter(orangeConfig); - this.challengeCache = new Map(); } /** @@ -26,16 +33,16 @@ export class OtpChallengeService implements IOtpChallengeService { try { // Appeler l'adaptateur Orange + const response = await this.orangeAdapter.initiateChallenge(request); + if (response.challengeId || true) { - this.challengeCache.set(response.challengeId, { request, response }); - - // Nettoyer le cache après expiration (par défaut 5 minutes) - const expirationTime = (response.expiresIn || 300) * 1000; - setTimeout(() => { - this.challengeCache.delete(response.challengeId); - }, expirationTime); + // this.cacheManager + await this.cacheService.set(response.challengeId , {request:request,response:response}, { + prefix: this.CACHE_PREFIX, + ttl: this.DEFAULT_TTL, + }); } return response; @@ -54,7 +61,7 @@ export class OtpChallengeService implements IOtpChallengeService { ): Promise { try { // Récupérer le challenge depuis le cache - const cached = this.challengeCache.get(challengeId); + const cached:any = this.cacheService.get(challengeId); if (!cached) { return { @@ -92,7 +99,7 @@ export class OtpChallengeService implements IOtpChallengeService { // Mettre à jour le cache if (response.status === OtpChallengeStatusEnum.VERIFIED) { - this.challengeCache.set(challengeId, { request: cached.request, response }); + this.cacheService.set(challengeId, { request: cached.request, response }); } return response;