Skip to content

Commit adbb838

Browse files
committed
feat: enhance WebAuthn support for MFA with new credential management
- Added functionality to begin and finish WebAuthn authentication challenges in the AuthController. - Implemented methods for managing WebAuthn credentials, including normalization and deletion in the AgentsController. - Updated the login and profile Vue components to support WebAuthn as a 2FA method, enhancing user experience with clear options for FIDO keys. - Introduced new utility functions for handling WebAuthn options and responses, improving the overall authentication flow. - Enhanced the AuthService to manage WebAuthn challenges and user verification, ensuring robust security measures are in place.
1 parent 88cb486 commit adbb838

6 files changed

Lines changed: 852 additions & 66 deletions

File tree

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

Lines changed: 109 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ import {
99
Patch,
1010
Post,
1111
Query,
12+
Req,
1213
Res,
1314
} from '@nestjs/common';
1415
import { ApiParam, ApiTags } from '@nestjs/swagger';
1516
import { FilterOptions, FilterSchema, SearchFilterOptions, SearchFilterSchema } from '~/_common/restools';
16-
import { Response } from 'express';
17+
import { Request, Response } from 'express';
1718
import { Types } from 'mongoose';
1819
import { AbstractController } from '~/_common/abstracts/abstract.controller';
1920
import { ApiCreateDecorator } from '~/_common/decorators/api-create.decorator';
@@ -144,26 +145,79 @@ export class AgentsController extends AbstractController {
144145
return Buffer.from(`${normalized}${pad}`, 'base64');
145146
}
146147

147-
private bufferToBase64url(buf: Uint8Array | Buffer): string {
148+
private bufferToBase64url(buf: Uint8Array | Buffer | string): string {
149+
if (typeof buf === 'string') return `${buf}`.trim();
148150
return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
149151
}
150152

151-
private getWebAuthnExpectedOrigin(): string {
152-
const raw = (process.env['SESAME_WEBAUTHN_ORIGIN'] || this.configService.get<string>('frontPwd.url') || '').trim();
153-
return raw.replace(/\/+$/, '');
153+
private normalizeWebAuthnCredentialId(value: string): string {
154+
const credentialId = `${value || ''}`.trim();
155+
if (!credentialId) return '';
156+
157+
try {
158+
const decoded = this.base64urlToBuffer(credentialId).toString('utf8').trim();
159+
const looksLikeNestedBase64url = /^[A-Za-z0-9_-]+$/.test(decoded) && decoded.length >= 16;
160+
return looksLikeNestedBase64url ? decoded : credentialId;
161+
} catch {
162+
return credentialId;
163+
}
164+
}
165+
166+
private getWebAuthnExpectedOrigin(req?: Request): string {
167+
const explicitOrigin = (process.env['SESAME_WEBAUTHN_ORIGIN'] || '').trim();
168+
if (explicitOrigin) return explicitOrigin.replace(/\/+$/, '');
169+
170+
const requestOrigin = this.getRequestOrigin(req);
171+
if (requestOrigin) return requestOrigin;
172+
173+
const fallbackOrigin = this.getForwardedOrigin(req);
174+
if (fallbackOrigin) return fallbackOrigin;
175+
176+
const hostOrigin = this.getHostOrigin(req);
177+
if (hostOrigin) return hostOrigin;
178+
179+
const configuredOrigin = (this.configService.get<string>('frontPwd.url') || '').trim();
180+
return configuredOrigin.replace(/\/+$/, '');
154181
}
155182

156-
private getWebAuthnRpId(): string {
183+
private getWebAuthnRpId(req?: Request): string {
157184
const env = (process.env['SESAME_WEBAUTHN_RP_ID'] || '').trim();
158185
if (env) return env;
159-
const origin = this.getWebAuthnExpectedOrigin();
186+
const origin = this.getWebAuthnExpectedOrigin(req);
160187
try {
161188
return new URL(origin).hostname;
162189
} catch {
163190
return '';
164191
}
165192
}
166193

194+
private getRequestOrigin(req?: Request): string {
195+
const rawOrigin = `${req?.headers?.origin || ''}`.trim();
196+
if (!rawOrigin) return '';
197+
try {
198+
const url = new URL(rawOrigin);
199+
return `${url.protocol}//${url.host}`;
200+
} catch {
201+
return '';
202+
}
203+
}
204+
205+
private getForwardedOrigin(req?: Request): string {
206+
const forwardedHost = `${req?.headers?.['x-forwarded-host'] || ''}`.split(',')[0]?.trim();
207+
if (!forwardedHost) return '';
208+
209+
const forwardedProto =
210+
`${req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http'}`.split(',')[0]?.trim() || 'http';
211+
return `${forwardedProto}://${forwardedHost}`.replace(/\/+$/, '');
212+
}
213+
214+
private getHostOrigin(req?: Request): string {
215+
const host = `${req?.headers?.host || ''}`.trim();
216+
if (!host) return '';
217+
const protocol = req?.protocol || 'http';
218+
return `${protocol}://${host}`.replace(/\/+$/, '');
219+
}
220+
167221
private getWebAuthnRpName(): string {
168222
return (process.env['SESAME_WEBAUTHN_RP_NAME'] || 'Sesame').trim() || 'Sesame';
169223
}
@@ -512,11 +566,12 @@ export class AgentsController extends AbstractController {
512566
public async beginWebAuthnRegisterSelf(
513567
@ReqIdentity() identity: AgentType,
514568
@Body() body: WebAuthnRegisterBeginDto,
569+
@Req() req: Request,
515570
@Res() res: Response,
516571
): Promise<Response> {
517-
const rpID = this.getWebAuthnRpId();
572+
const rpID = this.getWebAuthnRpId(req);
518573
const rpName = this.getWebAuthnRpName();
519-
const expectedOrigin = this.getWebAuthnExpectedOrigin();
574+
const expectedOrigin = this.getWebAuthnExpectedOrigin(req);
520575
if (!rpID || !expectedOrigin) {
521576
throw new BadRequestException('WebAuthn non configuré (RP ID / origin manquants)');
522577
}
@@ -526,10 +581,10 @@ export class AgentsController extends AbstractController {
526581
? (currentAgent as any).security.u2fKey
527582
: [];
528583
const excludeCredentials = existing
529-
.map((k: any) => `${k?.credentialId || ''}`.trim())
584+
.map((k: any) => this.normalizeWebAuthnCredentialId(`${k?.credentialId || ''}`))
530585
.filter((id: string) => id.length > 0)
531586
.map((id: string) => ({
532-
id: this.base64urlToBuffer(id),
587+
id,
533588
type: 'public-key' as const,
534589
}));
535590

@@ -610,7 +665,7 @@ export class AgentsController extends AbstractController {
610665
: {};
611666

612667
const existing = Array.isArray((currentSecurity as any)?.u2fKey) ? ((currentSecurity as any).u2fKey as any[]) : [];
613-
if (existing.some((k) => `${k?.credentialId || ''}`.trim() === credentialIdB64u)) {
668+
if (existing.some((k) => this.normalizeWebAuthnCredentialId(`${k?.credentialId || ''}`) === credentialIdB64u)) {
614669
throw new BadRequestException('Cette clé est déjà enregistrée');
615670
}
616671

@@ -645,6 +700,48 @@ export class AgentsController extends AbstractController {
645700
});
646701
}
647702

703+
@Delete('me/mfa/webauthn/:credentialId')
704+
@RequireMfa()
705+
public async deleteWebAuthnCredentialSelf(
706+
@ReqIdentity() identity: AgentType,
707+
@Param('credentialId') credentialId: string,
708+
@Res() res: Response,
709+
): Promise<Response> {
710+
const normalizedCredentialId = `${credentialId || ''}`.trim();
711+
if (!normalizedCredentialId) {
712+
throw new BadRequestException('Identifiant de clé requis');
713+
}
714+
715+
const currentAgent = await this._service.findById<Agents>(identity._id as Types.ObjectId);
716+
const currentSecurity =
717+
currentAgent?.security && typeof currentAgent.security === 'object'
718+
? typeof (currentAgent.security as any).toObject === 'function'
719+
? (currentAgent.security as any).toObject()
720+
: { ...(currentAgent.security as unknown as Record<string, unknown>) }
721+
: {};
722+
723+
const existing = Array.isArray((currentSecurity as any)?.u2fKey) ? ((currentSecurity as any).u2fKey as any[]) : [];
724+
const next = existing.filter((key) => `${key?.credentialId || ''}`.trim() !== normalizedCredentialId);
725+
if (next.length === existing.length) {
726+
throw new BadRequestException('Clé de sécurité introuvable');
727+
}
728+
729+
const data = await this._service.update(
730+
identity._id as Types.ObjectId,
731+
{
732+
security: {
733+
...currentSecurity,
734+
u2fKey: next,
735+
},
736+
} as AgentsUpdateDto,
737+
);
738+
739+
return res.status(HttpStatus.OK).json({
740+
statusCode: HttpStatus.OK,
741+
data: this.sanitizeAgentPayload(data, { includeOtpKey: true }),
742+
});
743+
}
744+
648745
/**
649746
* Met à jour un agent existant
650747
*

0 commit comments

Comments
 (0)