Skip to content

Commit e4539f4

Browse files
committed
feat: add preflight MFA endpoint and service method for enhanced authentication flow
- Introduced a new preflight endpoint in AuthController to check for MFA requirements without creating a session. - Implemented preflightLocalMfa method in AuthService to handle MFA challenge logic, including brute force protection and user validation. - Updated the login Vue component to utilize the new preflight endpoint for improved user experience during authentication.
1 parent 0e8cb1c commit e4539f4

3 files changed

Lines changed: 78 additions & 5 deletions

File tree

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,30 @@ export class AuthController extends AbstractController {
176176
});
177177
}
178178

179+
@Post('local/preflight')
180+
@ApiOperation({ summary: 'Préflight MFA (ne crée pas de session)' })
181+
public async preflightLocal(
182+
@Res() res: Response,
183+
@Req() req: Request,
184+
@Body() body: LocalLoginBody,
185+
): Promise<Response> {
186+
const payload = body?.body && typeof body.body === 'object' ? body.body : body;
187+
const ip = resolveClientIp(req) ?? null;
188+
const username = `${payload?.username || ''}`.trim();
189+
const password = `${payload?.password || ''}`;
190+
191+
const preflight = await this.service.preflightLocalMfa(username, password, ip ?? undefined);
192+
if (!preflight?.requires2fa) {
193+
return res.status(HttpStatus.OK).json({ requires2fa: false });
194+
}
195+
196+
return res.status(HttpStatus.OK).json({
197+
requires2fa: true,
198+
challengeToken: preflight.challengeToken,
199+
method: 'totp',
200+
});
201+
}
202+
179203
@Post('local/2fa/verify')
180204
@ApiOperation({ summary: 'Validation du code TOTP pour finaliser la connexion' })
181205
public async verifyLocal2fa(

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,48 @@ export class AuthService extends AbstractService implements OnModuleInit {
182182
}
183183
}
184184

185+
public async preflightLocalMfa(
186+
username: string,
187+
password: string,
188+
clientIp?: string,
189+
): Promise<{ requires2fa: true; challengeToken: string } | { requires2fa: false }> {
190+
const ip = this.normalizeClientIp(clientIp);
191+
const normalizedUsername = `${username || ''}`.trim();
192+
const normalizedPassword = `${password || ''}`;
193+
if (!normalizedUsername || !normalizedPassword.trim()) return { requires2fa: false };
194+
195+
try {
196+
const block = await this.getLocalBruteforceBlock({ username: normalizedUsername, ip });
197+
if (block.blocked) {
198+
// Préflight choisi en 200 générique: ne pas révéler le blocage, mais ne pas offrir de contournement.
199+
await this.registerLocalBruteforceFailure({ username: normalizedUsername, ip });
200+
return { requires2fa: false };
201+
}
202+
203+
const user = await this.agentsService.findOne<Agents>({ username: normalizedUsername });
204+
if (!user || !(await argon2Verify(user.password, normalizedPassword))) {
205+
await this.registerLocalBruteforceFailure({ username: normalizedUsername, ip });
206+
return { requires2fa: false };
207+
}
208+
209+
if (user?.state?.current !== AgentState.ACTIVE) return { requires2fa: false };
210+
if (!this.isClientIpAllowed(user?.security?.allowedNetworks, ip)) return { requires2fa: false };
211+
212+
// Credentials valides: reset du compteur pour éviter de pénaliser un utilisateur légitime.
213+
await this.clearLocalBruteforceState({ username: normalizedUsername, ip });
214+
215+
if (!this.isTotpEnabledForUser(user)) return { requires2fa: false };
216+
const challengeToken = await this.createMfaChallenge(user);
217+
return { requires2fa: true, challengeToken };
218+
} catch (e) {
219+
this.logger.warn(
220+
`[preflight-mfa] failed username=${normalizedUsername} ip=${ip || 'n/a'}`,
221+
e instanceof Error ? e.stack : undefined,
222+
);
223+
return { requires2fa: false };
224+
}
225+
}
226+
185227
public async getLocalBruteforceBlock(params: {
186228
username: string;
187229
ip: string | null;

apps/web/src/pages/login.vue

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ export default defineNuxtComponent({
114114
isRecord(value: unknown): value is Record<string, unknown> {
115115
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
116116
},
117+
getAuthUser(auth: unknown): Record<string, unknown> | null {
118+
if (!this.isRecord(auth)) return null
119+
const user = auth['user']
120+
if (this.isRecord(user)) return user
121+
if (this.isRecord(user) && this.isRecord((user as Record<string, unknown>)['value'])) {
122+
return (user as Record<string, unknown>)['value'] as Record<string, unknown>
123+
}
124+
return null
125+
},
117126
getErrorStatus(error: unknown): number | null {
118127
const anyErr = this.isRecord(error) ? error : {}
119128
const response = this.isRecord(anyErr.response) ? anyErr.response : {}
@@ -245,8 +254,7 @@ export default defineNuxtComponent({
245254
},
246255
})
247256
await auth.fetchUser()
248-
const authAny = auth as unknown as { user?: { value?: unknown } | unknown }
249-
const authUser = (this.isRecord(authAny.user) ? authAny.user : null) || (this.isRecord(authAny.user?.value) ? authAny.user.value : null)
257+
const authUser = this.getAuthUser(auth)
250258
const responsePayload = this.extractPayload(response)
251259
const uri = this.getPostLoginRedirect((this.isRecord(authUser) ? (authUser.baseURL as string | undefined) : undefined) || (responsePayload.uri as string | undefined))
252260
this.showTotpDialog = false
@@ -272,7 +280,7 @@ export default defineNuxtComponent({
272280
const auth = useAuth()
273281
let response: unknown = null
274282
if (!this.requires2fa) {
275-
const preAuthResponse = await this.$http.post('/core/auth/local', {
283+
const preAuthResponse = await this.$http.post('/core/auth/local/preflight', {
276284
body: {
277285
username: this.formData.username,
278286
password: this.formData.password,
@@ -302,8 +310,7 @@ export default defineNuxtComponent({
302310
return
303311
}
304312
await auth.fetchUser()
305-
const authAny = auth as unknown as { user?: { value?: unknown } | unknown }
306-
const authUser = (this.isRecord(authAny.user) ? authAny.user : null) || (this.isRecord(authAny.user?.value) ? authAny.user.value : null)
313+
const authUser = this.getAuthUser(auth)
307314
const responsePayload = this.extractPayload(response)
308315
const uri = this.getPostLoginRedirect((this.isRecord(authUser) ? (authUser.baseURL as string | undefined) : undefined) || (responsePayload.uri as string | undefined))
309316
await this.$router.push(uri)

0 commit comments

Comments
 (0)