Skip to content

Commit 13e26db

Browse files
committed
feat: integrate WebAuthn support for enhanced security in agent authentication
- Added WebAuthn registration and verification endpoints in the AgentsController to support FIDO2 security keys. - Updated the SecurityPart schema and DTOs to include U2F key credentials, enhancing the security model for agents. - Implemented methods for handling WebAuthn challenges and responses, ensuring secure registration of security keys. - Enhanced the profile page to display registered security keys and provide an interface for adding new keys. - Updated package dependencies to include @simplewebauthn libraries for WebAuthn functionality.
1 parent 210230f commit 13e26db

14 files changed

Lines changed: 875 additions & 48 deletions

File tree

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@nestjs/terminus": "^11.0.0",
3737
"@sentry/nestjs": "^10.25.0",
3838
"@sentry/profiling-node": "^10.25.0",
39+
"@simplewebauthn/server": "^13.3.0",
3940
"@tacxou/nestjs_module_factorydrive": "^1.1.6",
4041
"@tacxou/nestjs_module_factorydrive-s3": "^1.0.5",
4142
"ajv": "^8.16.0",

apps/api/src/core/agents/_dto/parts/security.part.dto.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
1-
import { ApiProperty } from '@nestjs/swagger'
2-
import { IsString, IsArray, IsBoolean, IsOptional } from 'class-validator'
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
3+
4+
export class U2fKeyCredentialDTO {
5+
@IsString()
6+
@ApiProperty()
7+
public credentialId: string;
8+
9+
@IsString()
10+
@IsOptional()
11+
@ApiProperty({ required: false })
12+
public name?: string;
13+
14+
@IsArray()
15+
@IsString({ each: true })
16+
@IsOptional()
17+
@ApiProperty({ type: [String], required: false })
18+
public transports?: string[];
19+
20+
@IsOptional()
21+
@ApiProperty({ required: false, type: Number })
22+
public signCount?: number;
23+
24+
@IsOptional()
25+
@ApiProperty({ required: false, type: String })
26+
public createdAt?: string;
27+
}
328

429
/**
530
* DTO pour la partie sécurité d'un agent.
@@ -43,7 +68,7 @@ export class SecurityPartDTO {
4368
*/
4469
@IsOptional()
4570
@ApiProperty({ type: [String] })
46-
public oldPasswords?: string[]
71+
public oldPasswords?: string[];
4772

4873
/**
4974
* Clé secrète pour l'authentification OTP (One-Time Password).
@@ -56,20 +81,19 @@ export class SecurityPartDTO {
5681
@IsString()
5782
@IsOptional()
5883
@ApiProperty()
59-
public otpKey?: string
84+
public otpKey?: string;
6085

6186
/**
6287
* Clés U2F/FIDO enregistrées pour l'authentification matérielle.
6388
* Tableau des identifiants de clés de sécurité physiques.
6489
*
65-
* @type {string[]}
90+
* @type {U2fKeyCredentialDTO[]}
6691
* @optional
6792
*/
6893
@IsArray()
69-
@IsString({ each: true })
7094
@IsOptional()
71-
@ApiProperty({ type: [String] })
72-
public u2fKey?: string[]
95+
@ApiProperty({ type: [U2fKeyCredentialDTO] })
96+
public u2fKey?: U2fKeyCredentialDTO[];
7397

7498
/**
7599
* Liste des réseaux/IP autorisés pour cet agent.
@@ -84,7 +108,7 @@ export class SecurityPartDTO {
84108
@IsString({ each: true })
85109
@IsOptional()
86110
@ApiProperty({ type: [String] })
87-
public allowedNetworks?: string[]
111+
public allowedNetworks?: string[];
88112

89113
/**
90114
* Indique si l'agent doit changer son mot de passe à la prochaine connexion.
@@ -97,7 +121,7 @@ export class SecurityPartDTO {
97121
@IsBoolean()
98122
@IsOptional()
99123
@ApiProperty()
100-
public changePwdAtNextLogin: boolean
124+
public changePwdAtNextLogin: boolean;
101125

102126
/**
103127
* Clé secrète unique de l'agent.
@@ -111,5 +135,5 @@ export class SecurityPartDTO {
111135
@IsString()
112136
@IsOptional()
113137
@ApiProperty()
114-
public secretKey?: string
138+
public secretKey?: string;
115139
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsOptional, IsString } from 'class-validator';
3+
4+
export class WebAuthnRegisterBeginDto {
5+
@IsOptional()
6+
@IsString()
7+
@ApiProperty({ required: false, description: 'Nom affiché pour la clé (ex: “YubiKey bureau”)' })
8+
public name?: string;
9+
}
10+
11+
export class WebAuthnRegisterFinishDto {
12+
@ApiProperty({ description: 'Réponse WebAuthn retournée par navigator.credentials.create()' })
13+
// On évite de lier le DTO aux types internes WebAuthn pour ne pas rendre le build fragile.
14+
public response: Record<string, unknown>;
15+
16+
@IsOptional()
17+
@IsString()
18+
@ApiProperty({ required: false, description: 'Nom affiché pour la clé (ex: “YubiKey bureau”)' })
19+
public name?: string;
20+
}

apps/api/src/core/agents/_schemas/_parts/security.part.schema.ts

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
1-
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
2-
import { Document } from 'mongoose'
1+
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
2+
import { Document } from 'mongoose';
3+
4+
@Schema({ _id: false })
5+
export class U2fKeyCredential {
6+
@Prop({ type: String, required: true })
7+
public credentialId: string;
8+
9+
/**
10+
* Clé publique associée à la credential (encodée en base64url).
11+
*/
12+
@Prop({ type: String, required: true })
13+
public publicKey: string;
14+
15+
/**
16+
* Compteur de signature WebAuthn (anti-rejeu), fourni par l’authenticator.
17+
*/
18+
@Prop({ type: Number, required: true, default: 0 })
19+
public signCount: number;
20+
21+
@Prop({ type: [String], required: false, default: [] })
22+
public transports?: string[];
23+
24+
@Prop({ type: Date, required: true, default: () => new Date() })
25+
public createdAt: Date;
26+
27+
@Prop({ type: String, required: false, default: '' })
28+
public name?: string;
29+
}
30+
31+
export const U2fKeyCredentialSchema = SchemaFactory.createForClass(U2fKeyCredential);
332

433
/**
534
* Schéma Mongoose pour la partie sécurité des agents.
@@ -50,7 +79,7 @@ export class SecurityPart extends Document {
5079
type: [String],
5180
default: [],
5281
})
53-
public oldPasswords?: string[]
82+
public oldPasswords?: string[];
5483

5584
/**
5685
* Clé secrète pour l'authentification OTP (One-Time Password).
@@ -63,19 +92,20 @@ export class SecurityPart extends Document {
6392
@Prop({
6493
type: String,
6594
})
66-
public otpKey?: string
95+
public otpKey?: string;
6796

6897
/**
6998
* Clés U2F/FIDO enregistrées pour l'authentification matérielle.
7099
* Tableau des identifiants de clés de sécurité physiques enregistrées.
71100
*
72-
* @type {string[]}
101+
* @type {U2fKeyCredential[]}
73102
* @optional
74103
*/
75104
@Prop({
76-
type: [String],
105+
type: [U2fKeyCredentialSchema],
106+
default: [],
77107
})
78-
public u2fKey?: string[]
108+
public u2fKey?: U2fKeyCredential[];
79109

80110
/**
81111
* Liste des réseaux/IP autorisés pour cet agent.
@@ -89,12 +119,12 @@ export class SecurityPart extends Document {
89119
@Prop({
90120
type: [String],
91121
set: (value: string[] | string | null | undefined): string[] | undefined => {
92-
if (value === null || value === undefined) return undefined
93-
const values = Array.isArray(value) ? value : [value]
94-
return values.map((item) => `${item || ''}`.trim()).filter((item) => item.length > 0)
122+
if (value === null || value === undefined) return undefined;
123+
const values = Array.isArray(value) ? value : [value];
124+
return values.map((item) => `${item || ''}`.trim()).filter((item) => item.length > 0);
95125
},
96126
})
97-
public allowedNetworks?: string[]
127+
public allowedNetworks?: string[];
98128

99129
/**
100130
* Indique si l'agent doit changer son mot de passe à la prochaine connexion.
@@ -108,7 +138,7 @@ export class SecurityPart extends Document {
108138
type: Boolean,
109139
default: false,
110140
})
111-
public changePwdAtNextLogin: boolean
141+
public changePwdAtNextLogin: boolean;
112142

113143
/**
114144
* Clé secrète unique de l'agent.
@@ -122,10 +152,10 @@ export class SecurityPart extends Document {
122152
@Prop({
123153
type: String,
124154
})
125-
public secretKey: string
155+
public secretKey: string;
126156
}
127157

128158
/**
129159
* Factory pour créer le schéma Mongoose à partir de la classe SecurityPart.
130160
*/
131-
export const SecurityPartSchema = SchemaFactory.createForClass(SecurityPart)
161+
export const SecurityPartSchema = SchemaFactory.createForClass(SecurityPart);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { BadRequestException } from '@nestjs/common';
2+
import { verify as argon2Verify } from 'argon2';
3+
4+
export async function assertAgentPasswordNotReused(params: {
5+
newPassword: string;
6+
currentHash: string;
7+
oldHashes?: string[] | null;
8+
}): Promise<void> {
9+
const newPassword = `${params?.newPassword || ''}`;
10+
const currentHash = `${params?.currentHash || ''}`;
11+
const oldHashes = Array.isArray(params?.oldHashes) ? params.oldHashes : [];
12+
13+
if (currentHash) {
14+
const matchesCurrent = await argon2Verify(currentHash, newPassword);
15+
if (matchesCurrent) {
16+
throw new BadRequestException({
17+
message: 'Le mot de passe a déjà été utilisé récemment',
18+
error: 'Bad Request',
19+
statusCode: 400,
20+
});
21+
}
22+
}
23+
24+
for (const hash of oldHashes) {
25+
const h = `${hash || ''}`;
26+
if (!h) continue;
27+
const matches = await argon2Verify(h, newPassword);
28+
if (matches) {
29+
throw new BadRequestException({
30+
message: 'Le mot de passe a déjà été utilisé récemment',
31+
error: 'Bad Request',
32+
statusCode: 400,
33+
});
34+
}
35+
}
36+
}
37+
38+
export function nextAgentOldPasswords(params: {
39+
currentHash: string;
40+
oldHashes?: string[] | null;
41+
maxCount: number;
42+
}): string[] {
43+
const currentHash = `${params?.currentHash || ''}`;
44+
const oldHashes = Array.isArray(params?.oldHashes) ? params.oldHashes : [];
45+
const maxCount = Math.max(0, Math.floor(Number(params?.maxCount || 0)));
46+
47+
const next = [
48+
...(currentHash ? [currentHash] : []),
49+
...oldHashes.map((h) => `${h || ''}`).filter((h) => h.length > 0),
50+
];
51+
52+
// dédoublonnage simple (les hashes Argon2 incluent un sel, donc doublons rares)
53+
const uniq: string[] = [];
54+
for (const h of next) {
55+
if (!uniq.includes(h)) uniq.push(h);
56+
}
57+
58+
if (maxCount <= 0) return [];
59+
return uniq.slice(0, maxCount);
60+
}

0 commit comments

Comments
 (0)