Skip to content

Commit 0e8cb1c

Browse files
committed
feat: enhance authentication flow with brute force protection and retry handling
- Implemented local and TOTP brute force protection in the AuthService, introducing mechanisms to block excessive login attempts based on username and IP. - Added logging for authentication attempts and failures, improving audit capabilities. - Updated AuthController to handle responses for blocked attempts, including appropriate HTTP status codes and retry-after headers. - Enhanced the login Vue component to manage UI states based on retry timing, preventing user actions during cooldown periods.
1 parent d9c6bd5 commit 0e8cb1c

3 files changed

Lines changed: 432 additions & 37 deletions

File tree

apps/api/src/core/auth/auth.controller.ts

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,30 @@ export class AuthController extends AbstractController {
7474
@Body() body: LocalLoginBody,
7575
): Promise<Response> {
7676
const payload = body?.body && typeof body.body === 'object' ? body.body : body;
77-
const user = await this.service.authenticateWithLocal(
78-
payload?.username,
79-
payload?.password,
80-
resolveClientIp(req) ?? undefined,
77+
const ip = resolveClientIp(req) ?? null;
78+
const username = `${payload?.username || ''}`.trim();
79+
80+
const bruteforce = await this.service.getLocalBruteforceBlock({
81+
username,
82+
ip,
83+
});
84+
this.logger.debug(
85+
`[anti-bf] local username=${username || 'N/A'} ip=${ip || 'n/a'} blocked=${bruteforce.blocked} retryAfter=${bruteforce.retryAfterSeconds}s`,
8186
);
87+
if (bruteforce.blocked) {
88+
await this.service.auditAuthAttempt({
89+
username: username || 'N/A',
90+
ip,
91+
result: 'failed',
92+
reason: 'bruteforce_blocked',
93+
});
94+
return res.status(HttpStatus.TOO_MANY_REQUESTS).set('Retry-After', `${bruteforce.retryAfterSeconds}`).json({
95+
message: 'Too many authentication attempts. Please retry later.',
96+
retryAfterSeconds: bruteforce.retryAfterSeconds,
97+
});
98+
}
99+
100+
const user = await this.service.authenticateWithLocal(username, payload?.password, ip ?? undefined);
82101
if (!user) throw new UnauthorizedException();
83102

84103
if (this.service.isTotpEnabledForUser(user)) {
@@ -96,11 +115,45 @@ export class AuthController extends AbstractController {
96115
});
97116
}
98117

118+
const totpBruteforce = await this.service.getTotpBruteforceBlock({
119+
ip,
120+
challengeToken: payload.challengeToken.trim(),
121+
});
122+
this.logger.debug(
123+
`[anti-bf] totp username=${user.username || 'N/A'} ip=${ip || 'n/a'} blocked=${totpBruteforce.blocked} retryAfter=${totpBruteforce.retryAfterSeconds}s`,
124+
);
125+
if (totpBruteforce.blocked) {
126+
await this.service.auditAuthAttempt({
127+
username: user.username,
128+
ip,
129+
result: 'failed',
130+
reason: 'totp_bruteforce_blocked',
131+
agentId: user._id,
132+
});
133+
return res.status(HttpStatus.TOO_MANY_REQUESTS).set('Retry-After', `${totpBruteforce.retryAfterSeconds}`).json({
134+
message: 'Too many OTP attempts. Please retry later.',
135+
retryAfterSeconds: totpBruteforce.retryAfterSeconds,
136+
});
137+
}
138+
99139
const verifiedUser = await this.service.verifyTotpChallenge(
100140
payload.challengeToken.trim(),
101141
payload.otpCode.trim(),
102142
);
103-
if (!verifiedUser || `${verifiedUser._id}` !== `${user._id}`) throw new UnauthorizedException();
143+
if (!verifiedUser || `${verifiedUser._id}` !== `${user._id}`) {
144+
await this.service.registerTotpBruteforceFailure({
145+
ip,
146+
challengeToken: payload.challengeToken.trim(),
147+
username: user.username,
148+
agentId: user._id,
149+
});
150+
throw new UnauthorizedException();
151+
}
152+
153+
await this.service.clearTotpBruteforceState({
154+
ip,
155+
challengeToken: payload.challengeToken.trim(),
156+
});
104157

105158
const tokens = await this.service.createTokens(verifiedUser, undefined, { mfaVerified: true });
106159
const uri =
@@ -125,12 +178,47 @@ export class AuthController extends AbstractController {
125178

126179
@Post('local/2fa/verify')
127180
@ApiOperation({ summary: 'Validation du code TOTP pour finaliser la connexion' })
128-
public async verifyLocal2fa(@Res() res: Response, @Body() body: VerifyTotpBody): Promise<Response> {
129-
const user = await this.service.verifyTotpChallenge(
130-
`${body?.challengeToken || ''}`.trim(),
131-
`${body?.otpCode || ''}`.trim(),
181+
public async verifyLocal2fa(
182+
@Res() res: Response,
183+
@Req() req: Request,
184+
@Body() body: VerifyTotpBody,
185+
): Promise<Response> {
186+
const ip = resolveClientIp(req) ?? null;
187+
const challengeToken = `${body?.challengeToken || ''}`.trim();
188+
189+
const totpBruteforce = await this.service.getTotpBruteforceBlock({
190+
ip,
191+
challengeToken,
192+
});
193+
this.logger.debug(
194+
`[anti-bf] totp-verify ip=${ip || 'n/a'} tokenPrefix=${challengeToken.slice(0, 8) || 'n/a'} blocked=${totpBruteforce.blocked} retryAfter=${totpBruteforce.retryAfterSeconds}s`,
132195
);
133-
if (!user) throw new UnauthorizedException();
196+
if (totpBruteforce.blocked) {
197+
await this.service.auditAuthAttempt({
198+
username: 'N/A',
199+
ip,
200+
result: 'failed',
201+
reason: 'totp_bruteforce_blocked',
202+
});
203+
return res.status(HttpStatus.TOO_MANY_REQUESTS).set('Retry-After', `${totpBruteforce.retryAfterSeconds}`).json({
204+
message: 'Too many OTP attempts. Please retry later.',
205+
retryAfterSeconds: totpBruteforce.retryAfterSeconds,
206+
});
207+
}
208+
209+
const user = await this.service.verifyTotpChallenge(challengeToken, `${body?.otpCode || ''}`.trim());
210+
if (!user) {
211+
await this.service.registerTotpBruteforceFailure({
212+
ip,
213+
challengeToken,
214+
});
215+
throw new UnauthorizedException();
216+
}
217+
218+
await this.service.clearTotpBruteforceState({
219+
ip,
220+
challengeToken,
221+
});
134222
const tokens = await this.service.createTokens(user, undefined, { mfaVerified: true });
135223
const uri = typeof user?.baseURL === 'string' && user.baseURL.trim().length > 0 ? user.baseURL.trim() : '/';
136224
return res.status(HttpStatus.OK).json({

0 commit comments

Comments
 (0)