@@ -56,6 +56,12 @@ export class PasswdService extends AbstractService {
5656
5757 public static readonly TOKEN_ALGORITHM = 'aes-256-gcm' ;
5858
59+ protected readonly BRUTEFORCE_FAIL_PREFIX = 'passwd:bf:fail' ;
60+ protected readonly BRUTEFORCE_BLOCK_PREFIX = 'passwd:bf:block' ;
61+ protected readonly BRUTEFORCE_THRESHOLD = 5 ;
62+ protected readonly BRUTEFORCE_FAIL_WINDOW_SECONDS = 6 * 60 * 60 ;
63+ protected readonly BRUTEFORCE_COOLDOWN_STEPS_SECONDS = [ 60 , 300 , 1800 , 3600 ] ;
64+
5965 public constructor (
6066 protected readonly backends : BackendsService ,
6167 protected readonly identities : IdentitiesCrudService ,
@@ -232,7 +238,21 @@ export class PasswdService extends AbstractService {
232238 }
233239
234240 //Changement du password
235- public async change ( passwdDto : ChangePasswordDto ) : Promise < [ Jobs , any ] > {
241+ public async change ( passwdDto : ChangePasswordDto , clientIp ?: string | null ) : Promise < [ Jobs , any ] > {
242+ const ip = this . normalizeClientIp ( clientIp ) ;
243+ const uid = `${ passwdDto ?. uid || '' } ` . trim ( ) ;
244+ const block = await this . getChangePasswordBruteforceBlock ( { uid, ip } ) ;
245+ if ( block . blocked ) {
246+ throw new HttpException (
247+ {
248+ message : 'Too many password change attempts. Please retry later.' ,
249+ retryAfterSeconds : block . retryAfterSeconds ,
250+ } ,
251+ HttpStatus . TOO_MANY_REQUESTS ,
252+ ) ;
253+ }
254+
255+ let shouldCountFailure = false ;
236256 try {
237257 const identity = ( await this . identities . findOne ( {
238258 'inetOrgPerson.uid' : passwdDto . uid ,
@@ -253,6 +273,7 @@ export class PasswdService extends AbstractService {
253273 }
254274 await this . passwordHistory . assertNotReused ( identity . _id , passwdDto . newPassword ) ;
255275 //tout est ok en envoie au backend
276+ shouldCountFailure = true ;
256277 const result = await this . backends . executeJob (
257278 ActionType . IDENTITY_PASSWORD_CHANGE ,
258279 identity . _id ,
@@ -274,12 +295,28 @@ export class PasswdService extends AbstractService {
274295 await this . identities . model . updateOne ( { _id : identity . _id } , { dataStatus : DataStatusEnum . ACTIVE } ) ;
275296 await this . passwordHistory . recordPassword ( identity . _id , passwdDto . newPassword , 'change' ) ;
276297 await this . updatePasswordUsageExpiration ( identity . _id ) ;
298+ await this . clearChangePasswordBruteforceState ( { uid, ip } ) ;
277299 return result ;
278300 } catch ( e ) {
301+ if ( e instanceof HttpException && e . getStatus ?.( ) === HttpStatus . TOO_MANY_REQUESTS ) throw e ;
302+
279303 let job = undefined ;
280304 let _debug = undefined ;
281305 this . logger . error ( 'Error while changing password. ' + e + ` (uid=${ passwdDto ?. uid } )` ) ;
282306
307+ if ( shouldCountFailure ) {
308+ const failure = await this . registerChangePasswordBruteforceFailure ( { uid, ip } ) ;
309+ if ( failure . blocked ) {
310+ throw new HttpException (
311+ {
312+ message : 'Too many password change attempts. Please retry later.' ,
313+ retryAfterSeconds : failure . retryAfterSeconds ,
314+ } ,
315+ HttpStatus . TOO_MANY_REQUESTS ,
316+ ) ;
317+ }
318+ }
319+
283320 if ( e ?. response ?. status === HttpStatus . BAD_REQUEST ) {
284321 job = { } ;
285322 job [ 'status' ] = e ?. response ?. job ?. status ;
@@ -299,6 +336,74 @@ export class PasswdService extends AbstractService {
299336 }
300337 }
301338
339+ protected normalizeClientIp ( clientIp ?: string | null ) : string | null {
340+ if ( ! clientIp || typeof clientIp !== 'string' ) return null ;
341+ let value = clientIp . trim ( ) ;
342+ if ( ! value ) return null ;
343+ if ( value . startsWith ( '::ffff:' ) ) value = value . slice ( 7 ) ;
344+ const zoneIdx = value . indexOf ( '%' ) ;
345+ if ( zoneIdx > - 1 ) value = value . slice ( 0 , zoneIdx ) ;
346+ if ( value === '::1' ) return '127.0.0.1' ;
347+ return value ;
348+ }
349+
350+ protected hashKeyPart ( value : string ) : string {
351+ return crypto . createHash ( 'sha256' ) . update ( value ) . digest ( 'hex' ) . slice ( 0 , 32 ) ;
352+ }
353+
354+ protected getChangePasswordBruteforceFailKey ( params : { uid : string ; ip : string | null } ) : string {
355+ const ipPart = this . hashKeyPart ( params . ip || 'n/a' ) ;
356+ const uidPart = this . hashKeyPart ( `${ params . uid || '' } ` . trim ( ) . toLowerCase ( ) ) ;
357+ return [ this . BRUTEFORCE_FAIL_PREFIX , ipPart , uidPart ] . join ( ':' ) ;
358+ }
359+
360+ protected getChangePasswordBruteforceBlockKey ( params : { uid : string ; ip : string | null } ) : string {
361+ const ipPart = this . hashKeyPart ( params . ip || 'n/a' ) ;
362+ const uidPart = this . hashKeyPart ( `${ params . uid || '' } ` . trim ( ) . toLowerCase ( ) ) ;
363+ return [ this . BRUTEFORCE_BLOCK_PREFIX , ipPart , uidPart ] . join ( ':' ) ;
364+ }
365+
366+ public async getChangePasswordBruteforceBlock ( params : {
367+ uid : string ;
368+ ip : string | null ;
369+ } ) : Promise < { blocked : boolean ; retryAfterSeconds : number } > {
370+ const blockKey = this . getChangePasswordBruteforceBlockKey ( params ) ;
371+ const ttl = await this . redis . ttl ( blockKey ) ;
372+ const retryAfterSeconds = Number . isFinite ( ttl ) && ttl > 0 ? ttl : 0 ;
373+ return { blocked : retryAfterSeconds > 0 , retryAfterSeconds } ;
374+ }
375+
376+ public async registerChangePasswordBruteforceFailure ( params : {
377+ uid : string ;
378+ ip : string | null ;
379+ } ) : Promise < { count : number ; blocked : boolean ; retryAfterSeconds : number } > {
380+ const failKey = this . getChangePasswordBruteforceFailKey ( params ) ;
381+ const count = await this . redis . incr ( failKey ) ;
382+ if ( count === 1 ) {
383+ await this . redis . expire ( failKey , this . BRUTEFORCE_FAIL_WINDOW_SECONDS ) ;
384+ }
385+
386+ if ( count < this . BRUTEFORCE_THRESHOLD ) {
387+ return { count, blocked : false , retryAfterSeconds : 0 } ;
388+ }
389+
390+ const stepIndex = Math . min (
391+ this . BRUTEFORCE_COOLDOWN_STEPS_SECONDS . length - 1 ,
392+ Math . max ( count - this . BRUTEFORCE_THRESHOLD , 0 ) ,
393+ ) ;
394+ const cooldownSeconds =
395+ this . BRUTEFORCE_COOLDOWN_STEPS_SECONDS [ stepIndex ] ?? this . BRUTEFORCE_COOLDOWN_STEPS_SECONDS . at ( - 1 ) ?? 60 ;
396+
397+ const blockKey = this . getChangePasswordBruteforceBlockKey ( params ) ;
398+ await this . redis . set ( blockKey , `${ count } ` , 'EX' , cooldownSeconds ) ;
399+ return { count, blocked : true , retryAfterSeconds : cooldownSeconds } ;
400+ }
401+
402+ public async clearChangePasswordBruteforceState ( params : { uid : string ; ip : string | null } ) : Promise < void > {
403+ await this . redis . del ( this . getChangePasswordBruteforceFailKey ( params ) ) ;
404+ await this . redis . del ( this . getChangePasswordBruteforceBlockKey ( params ) ) ;
405+ }
406+
302407 // Genere un token pour les autres methodes
303408 public async askToken ( askToken : AskTokenDto , k , ttl : number ) : Promise < string > {
304409 try {
0 commit comments