Skip to content

Commit 1a71fa6

Browse files
committed
feat: enhance password change functionality with brute force protection
- Updated the change password endpoint to include client IP tracking and brute force protection mechanisms. - Implemented logic to block excessive password change attempts, returning appropriate HTTP status and retry information. - Refactored the PasswdService to handle client IP normalization and manage brute force failure states. - Enhanced error handling for password change attempts, improving security and user feedback.
1 parent 97083cc commit 1a71fa6

2 files changed

Lines changed: 135 additions & 13 deletions

File tree

apps/api/src/management/passwd/passwd.controller.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Body, Controller, Get, HttpStatus, Logger, Post, Res } from '@nestjs/common';
1+
import { Body, Controller, Get, HttpStatus, Logger, Post, Req, Res } from '@nestjs/common';
22
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
33
import { Response } from 'express';
44
import { Document } from 'mongoose';
@@ -12,6 +12,8 @@ import { PasswdadmService } from '~/settings/passwdadm.service';
1212
import { ChangePasswordDto } from './_dto/change-password.dto';
1313
import { ResetPasswordDto } from './_dto/reset-password.dto';
1414
import { PasswdService } from './passwd.service';
15+
import { resolveClientIp } from '~/_common/functions/resolve-client-ip';
16+
import { HttpException } from '@nestjs/common';
1517

1618
@Controller('passwd')
1719
@ApiTags('management/passwd')
@@ -26,21 +28,36 @@ export class PasswdController {
2628
@Post('change')
2729
@ApiOperation({ summary: 'Execute un job de changement de mot de passe sur le/les backends' })
2830
@ApiResponse({ status: HttpStatus.OK, description: 'Mot de passe synchronisé sur le/les backends' })
29-
public async change(@Body() body: ChangePasswordDto, @Res() res: Response): Promise<Response> {
31+
public async change(@Req() req: any, @Body() body: ChangePasswordDto, @Res() res: Response): Promise<Response> {
3032
const debug = {};
3133

32-
const [, data] = await this.passwdService.change(body);
33-
this.logger.log(`Call passwd change for : ${body.uid}`);
34+
try {
35+
const [, data] = await this.passwdService.change(body, resolveClientIp(req) ?? null);
36+
this.logger.log(`Call passwd change for : ${body.uid}`);
3437

35-
if (process.env.NODE_ENV === 'development') {
36-
debug['_debug'] = data;
37-
}
38+
if (process.env.NODE_ENV === 'development') {
39+
debug['_debug'] = data;
40+
}
3841

39-
return res.status(HttpStatus.OK).json({
40-
message: 'Password changed',
41-
status: 0,
42-
...debug,
43-
});
42+
return res.status(HttpStatus.OK).json({
43+
message: 'Password changed',
44+
status: 0,
45+
...debug,
46+
});
47+
} catch (e) {
48+
if (e instanceof HttpException && e.getStatus?.() === HttpStatus.TOO_MANY_REQUESTS) {
49+
const payload = e.getResponse() as any;
50+
const retryAfterSeconds =
51+
payload && typeof payload === 'object' && Number.isFinite(Number(payload.retryAfterSeconds))
52+
? Number(payload.retryAfterSeconds)
53+
: 0;
54+
if (retryAfterSeconds > 0) {
55+
res.set('Retry-After', `${retryAfterSeconds}`);
56+
}
57+
return res.status(HttpStatus.TOO_MANY_REQUESTS).json(payload);
58+
}
59+
throw e;
60+
}
4461
}
4562

4663
@Post('resetbycode')

apps/api/src/management/passwd/passwd.service.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)