Skip to content

Commit fb89b45

Browse files
committed
feat: implement MFA enhancements and agent authentication improvements
- Added MFA (Multi-Factor Authentication) support by introducing MfaGuard and related decorators to enforce MFA requirements on specific routes. - Enhanced the authentication process to include TOTP (Time-based One-Time Password) verification, allowing for a more secure login experience. - Updated the AgentsController to handle MFA challenges and verification, ensuring that only users who pass MFA can access sensitive operations. - Refactored the AuthService to manage MFA challenges and verification logic, improving the overall security of the authentication flow. - Implemented additional logging for MFA-related actions to enhance audit capabilities and track user authentication attempts more effectively.
1 parent 7db058f commit fb89b45

8 files changed

Lines changed: 91 additions & 14 deletions

File tree

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
23
import { Reflector } from '@nestjs/core';
34
import { META_REQUIRE_MFA } from '~/_common/decorators/require-mfa.decorator';
45

56
@Injectable()
67
export class MfaGuard implements CanActivate {
7-
public constructor(private readonly reflector: Reflector) {}
8+
public constructor(
9+
private readonly reflector: Reflector,
10+
private readonly config: ConfigService,
11+
) {}
812

913
public canActivate(context: ExecutionContext): boolean {
1014
const requiresMfa = this.reflector.getAllAndOverride<boolean>(META_REQUIRE_MFA, [
@@ -13,8 +17,16 @@ export class MfaGuard implements CanActivate {
1317
]);
1418
if (!requiresMfa) return true;
1519

16-
const request = context.switchToHttp().getRequest<{ user?: { mfaVerified?: boolean } }>();
17-
if (request?.user?.mfaVerified) return true;
18-
throw new ForbiddenException('MFA required');
20+
const request = context.switchToHttp().getRequest<{ user?: { mfaVerified?: boolean; mfaVerifiedAt?: number | null } }>();
21+
if (!request?.user?.mfaVerified) throw new ForbiddenException('MFA required');
22+
23+
const maxAgeSeconds = this.config.get<number>('application.mfaStepUpMaxAgeSeconds', 5 * 60);
24+
const maxAgeMs = Math.max(0, maxAgeSeconds) * 1000;
25+
if (maxAgeMs <= 0) return true;
26+
27+
const verifiedAt = typeof request.user.mfaVerifiedAt === 'number' ? request.user.mfaVerifiedAt : null;
28+
if (!verifiedAt) throw new ForbiddenException('MFA required');
29+
if (Date.now() - verifiedAt > maxAgeMs) throw new ForbiddenException('MFA required');
30+
return true;
1931
}
2032
}

apps/api/src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ export interface ConfigInstance {
220220
key: string;
221221
cert: string;
222222
}
223+
mfaStepUpMaxAgeSeconds: number
223224
}
224225
helmet: HelmetOptions
225226
mongoose: {
@@ -319,6 +320,7 @@ export default (): ConfigInstance => ({
319320
key: process.env['SESAME_HTTPS_PATH_KEY'] || '',
320321
cert: process.env['SESAME_HTTPS_PATH_CERT'] || '',
321322
},
323+
mfaStepUpMaxAgeSeconds: parseInt(process.env['SESAME_MFA_STEPUP_MAX_AGE_SECONDS'] || `${5 * 60}`, 10),
322324
},
323325
helmet: {
324326
contentSecurityPolicy: {

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,11 @@ export class AgentsController extends AbstractController {
378378
}
379379

380380
@Post('me/mfa/totp/disable')
381-
public async disableTotpSelf(@ReqIdentity() identity: AgentType, @Res() res: Response): Promise<Response> {
381+
public async disableTotpSelf(
382+
@ReqIdentity() identity: AgentType,
383+
@Body() body: { otpCode?: string },
384+
@Res() res: Response,
385+
): Promise<Response> {
382386
const currentAgent = await this._service.findById<Agents>(identity._id as Types.ObjectId);
383387
const currentSecurity =
384388
currentAgent?.security && typeof currentAgent.security === 'object'
@@ -387,6 +391,26 @@ export class AgentsController extends AbstractController {
387391
: { ...(currentAgent.security as unknown as Record<string, unknown>) }
388392
: {};
389393

394+
const currentOtpKey = `${(currentSecurity as any)?.otpKey || ''}`.trim().replace(/\s+/g, '').toUpperCase();
395+
if (!currentOtpKey) {
396+
throw new BadRequestException('MFA TOTP non activé');
397+
}
398+
399+
const otpCode = `${body?.otpCode || ''}`.trim();
400+
if (!otpCode) {
401+
throw new BadRequestException('Code OTP requis');
402+
}
403+
404+
const isValid = speakeasy.totp.verify({
405+
secret: currentOtpKey,
406+
token: otpCode,
407+
encoding: 'base32',
408+
window: 1,
409+
});
410+
if (!isValid) {
411+
throw new BadRequestException('Code OTP invalide');
412+
}
413+
390414
const data = await this._service.update(
391415
identity._id as Types.ObjectId,
392416
{

apps/api/src/core/auth/_strategies/jwt.strategy.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
2828
// noinspection JSUnusedGlobalSymbols
2929
public async validate(
3030
_: Request,
31-
payload: JwtPayload & { identity: AgentType; mfaVerified?: boolean },
31+
payload: JwtPayload & { identity: AgentType; mfaVerified?: boolean; mfaVerifiedAt?: number | null },
3232
done: VerifiedCallback,
3333
): Promise<void> {
3434
this.logger.verbose(`Atempt to authenticate with JTI: <${payload.jti}>`);
@@ -51,6 +51,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
5151
...payload?.identity,
5252
roles,
5353
mfaVerified: !!payload?.mfaVerified,
54+
mfaVerifiedAt: typeof payload?.mfaVerifiedAt === 'number' ? payload.mfaVerifiedAt : null,
5455
});
5556
}
5657
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,8 +385,9 @@ export class AuthService extends AbstractService implements OnModuleInit {
385385
const normalizedIdentity = typeof identity?.toObject === 'function' ? identity.toObject() : identity;
386386
const jwtid = `${identity._id}_${randomBytes(16).toString('hex')}`;
387387
const mfaVerified = !!options?.mfaVerified;
388+
const mfaVerifiedAt = mfaVerified ? Date.now() : null;
388389
const access_token = this.jwtService.sign(
389-
{ identity: pick(normalizedIdentity, ['_id', 'username', 'email', 'token', 'roles']), scopes, mfaVerified },
390+
{ identity: pick(normalizedIdentity, ['_id', 'username', 'email', 'token', 'roles']), scopes, mfaVerified, mfaVerifiedAt },
390391
{
391392
expiresIn: this.ACCESS_TOKEN_EXPIRES_IN,
392393
jwtid,
@@ -405,6 +406,7 @@ export class AuthService extends AbstractService implements OnModuleInit {
405406
JSON.stringify({
406407
identityId: identity._id,
407408
mfaVerified,
409+
mfaVerifiedAt,
408410
}),
409411
);
410412
}
@@ -419,6 +421,7 @@ export class AuthService extends AbstractService implements OnModuleInit {
419421
identity: userIdentity.toJSON(),
420422
refresh_token,
421423
mfaVerified,
424+
mfaVerifiedAt,
422425
}),
423426
'EX',
424427
this.ACCESS_TOKEN_EXPIRES_IN,

apps/web/nuxt.config.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,8 @@ export default defineNuxtConfig({
130130
logout: { url: `/api/core/auth/logout`, method: 'post' },
131131
user: { url: `/api/core/auth/session`, method: 'get' },
132132
},
133-
redirect: {
134-
logout: '/login',
135-
login: '/',
136-
},
137-
tokenType: 'Bearer',
138133
autoRefresh: true,
139-
},
134+
} as any,
140135
},
141136
stores: {
142137
pinia: {

apps/web/src/pages/profile.vue

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,31 @@ export default defineNuxtComponent({
354354
async disableTotp() {
355355
this.pendingTotpDisable = true
356356
try {
357-
await this.$http.post('/core/agents/me/mfa/totp/disable')
357+
const otpCode = await new Promise<string>((resolve, reject) => {
358+
this.$q
359+
.dialog({
360+
title: 'Désactiver le MFA',
361+
message: 'Entrez votre code TOTP à 6 chiffres pour confirmer.',
362+
prompt: {
363+
model: '',
364+
type: 'text',
365+
isValid: (val: string) => /^\d{6}$/.test(String(val || '').trim()),
366+
},
367+
cancel: true,
368+
persistent: true,
369+
color: 'warning',
370+
ok: { label: 'Désactiver', color: 'warning' },
371+
})
372+
.onOk((val: string) => resolve(val))
373+
.onCancel(() => reject(new Error('Disable TOTP cancelled')))
374+
.onDismiss(() => reject(new Error('Disable TOTP dismissed')))
375+
})
376+
377+
await this.$http.post('/core/agents/me/mfa/totp/disable', {
378+
body: {
379+
otpCode: String(otpCode || '').trim(),
380+
},
381+
})
358382
this.isTotpEnabled = false
359383
this.cancelTotpSetup()
360384
this.$q.notify({

apps/web/src/plugins/http-mfa.client.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ export default defineNuxtPlugin((nuxtApp) => {
2020

2121
let inFlightStepUp: Promise<void> | null = null
2222

23+
const isSettingsContext = (): boolean => {
24+
try {
25+
const r = useRoute()
26+
return typeof r?.fullPath === 'string' && r.fullPath.startsWith('/settings')
27+
} catch {
28+
return false
29+
}
30+
}
2331
const ensureStepUp = async () => {
2432
if (inFlightStepUp) return await inFlightStepUp
2533
if (!rawHttpRef) throw new Error('HTTP client not ready')
@@ -121,12 +129,20 @@ export default defineNuxtPlugin((nuxtApp) => {
121129
const wrap = (fn: any, kind: 'read' | 'write') => {
122130
if (typeof fn !== 'function') return fn
123131
return async (...args: any[]) => {
132+
const url = args?.[0]
133+
// Never intercept the step-up endpoint itself.
134+
if (typeof url === 'string' && url.startsWith('/core/auth/mfa/step-up')) {
135+
return await fn(...args)
136+
}
137+
124138
try {
125139
return await fn(...args)
126140
} catch (error: any) {
127141
if (!isMfaRequiredError(error)) throw error
128142
// Only step-up for explicit "save" / write intents.
129143
if (kind !== 'write') throw error
144+
// Only prompt for step-up on sensitive settings pages.
145+
if (!isSettingsContext()) throw error
130146
await ensureStepUp()
131147
return await fn(...args)
132148
}

0 commit comments

Comments
 (0)