Skip to content

Commit 7eae5a6

Browse files
committed
feat: enhance password recheck functionality and HIBP key validation
- Introduced new settings in the password policy DTO for handling actions on compromised passwords during HIBP rechecks, allowing options for 'none', 'notify', or 'expire'. - Updated the PasswdadmController to validate the HIBP key status before enabling rechecks, ensuring proper configuration. - Enhanced the IdentitiesPwnedCommand to notify users or expire passwords based on the new policy settings. - Modified the password policy UI to include options for the new actions and display HIBP key status, improving user experience and security awareness.
1 parent c591ad4 commit 7eae5a6

File tree

4 files changed

+134
-5
lines changed

4 files changed

+134
-5
lines changed

apps/api/src/management/identities/identities.command.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import { Types } from "mongoose";
99
import axios from 'axios'
1010
import { PasswordHistoryService } from '~/management/password-history/password-history.service'
1111
import { PasswdadmService } from '~/settings/passwdadm.service'
12+
import { IdentitiesForcepasswordService } from '~/management/identities/identities-forcepassword.service'
13+
import { IdentitiesCrudService } from '~/management/identities/identities-crud.service'
14+
import { MailerService } from '@nestjs-modules/mailer'
15+
import { MailadmService } from '~/settings/mailadm.service'
16+
import { get } from 'radash'
1217

1318

1419
@SubCommand({ name: 'fingerprint' })
@@ -113,6 +118,10 @@ export class IdentitiesPwnedCommand extends CommandRunner {
113118
// Ici on a des services singleton, on utilise donc `get()`.
114119
const passwordHistory = this.moduleRef.get(PasswordHistoryService, { strict: false })
115120
const passwdadm = this.moduleRef.get(PasswdadmService, { strict: false })
121+
const identities = this.moduleRef.get(IdentitiesCrudService, { strict: false })
122+
const forcePwd = this.moduleRef.get(IdentitiesForcepasswordService, { strict: false })
123+
const mailer = this.moduleRef.get(MailerService, { strict: false })
124+
const mailadm = this.moduleRef.get(MailadmService, { strict: false })
116125

117126
const policies: any = await passwdadm.getPolicies()
118127
if (!policies?.pwnedRecheckEnabled) {
@@ -194,6 +203,43 @@ export class IdentitiesPwnedCommand extends CommandRunner {
194203

195204
if (pwnCount > 0) {
196205
this.logger.warn(`PWNED password detected: history=<${entry._id}> identityId=<${entry.identityId}> count=${pwnCount}`)
206+
207+
const action = (policies?.pwnedRecheckAction || 'none') as 'none' | 'notify' | 'expire'
208+
if (action === 'expire') {
209+
try {
210+
await forcePwd.needToChangePassword(String(entry.identityId))
211+
this.logger.warn(`PWNED action applied: expire password identityId=<${entry.identityId}>`)
212+
} catch (e) {
213+
this.logger.warn(`PWNED action failed (expire) for identityId=<${entry.identityId}>: ${e?.message || e}`)
214+
}
215+
} else if (action === 'notify') {
216+
try {
217+
const identity = await identities.model.findById(entry.identityId).lean()
218+
const mailAttribute = String(policies?.emailAttribute || '')
219+
const email = mailAttribute ? (get(identity as any, mailAttribute) as string) : null
220+
if (!email) {
221+
this.logger.warn(`PWNED notify skipped: no email (attribute=<${mailAttribute || 'n/a'}>) for identityId=<${entry.identityId}>`)
222+
} else {
223+
const smtpParams = await mailadm.getParams()
224+
const subject = 'Alerte sécurité : mot de passe compromis'
225+
const text =
226+
`Bonjour,\n\n` +
227+
`Un re-check de sécurité (HIBP) indique que votre mot de passe apparaît dans des fuites connues (occurrences: ${pwnCount}).\n` +
228+
`Nous vous recommandons de le changer dès que possible.\n\n` +
229+
`Ceci est une notification : votre accès n'est pas bloqué.\n`
230+
231+
await mailer.sendMail({
232+
from: smtpParams?.sender,
233+
to: email,
234+
subject,
235+
text,
236+
})
237+
this.logger.warn(`PWNED action applied: notified user identityId=<${entry.identityId}>`)
238+
}
239+
} catch (e) {
240+
this.logger.warn(`PWNED action failed (notify) for identityId=<${entry.identityId}>: ${e?.message || e}`)
241+
}
242+
}
197243
}
198244
} catch (e) {
199245
this.logger.warn(`HIBP range call failed for history <${entry._id}>: ${e?.message || e}`)

apps/api/src/settings/_dto/password-policy.dto.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ export class PasswordPoliciesDto {
6262
@ApiProperty({ example: 604800, description: 'Age max (secondes) avant re-check HIBP en cron', type: Number })
6363
public pwnedRecheckMaxAgeSeconds: number = 60 * 60 * 24 * 7;
6464

65+
@IsString()
66+
@ApiProperty({
67+
example: 'none',
68+
description: "Action à effectuer si un mot de passe est détecté comme compromis via le re-check HIBP ('none' | 'notify' | 'expire')",
69+
type: String,
70+
})
71+
public pwnedRecheckAction: 'none' | 'notify' | 'expire' = 'none';
72+
6573
@IsBoolean()
6674
@ApiProperty({ example: true, description: 'Mote de passe peut etre reinitialisé par sms', type: Boolean })
6775
public resetBySms: boolean = false;

apps/api/src/settings/passwdadm.controller.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import { Body, Controller, Get, HttpStatus, Logger, Post, Res } from '@nestjs/common'
1+
import { BadRequestException, Body, Controller, Get, HttpStatus, Logger, Post, Res } from '@nestjs/common'
22
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'
33
import { Response } from 'express'
44
import { PasswordPoliciesDto } from '~/settings/_dto/password-policy.dto'
55
import { PasswdadmService } from './passwdadm.service'
66
import { UseRoles } from '~/_common/decorators/use-roles.decorator'
77
import { AC_ACTIONS, AC_DEFAULT_POSSESSION } from '~/_common/types/ac-types'
8+
import { ConfigService } from '@nestjs/config'
89

910
@Controller('settings/passwdadm')
1011
@ApiTags('settings')
1112
export class PasswdadmController {
12-
public constructor(private passwdadmService: PasswdadmService) {}
13+
public constructor(
14+
private passwdadmService: PasswdadmService,
15+
private configService: ConfigService,
16+
) {}
1317

1418
@Post('setpolicies')
1519
@UseRoles({
@@ -20,6 +24,12 @@ export class PasswdadmController {
2024
@ApiOperation({ summary: 'enregistre la police de mdp' })
2125
@ApiResponse({ status: HttpStatus.OK })
2226
public async setPolicies(@Body() body: PasswordPoliciesDto, @Res() res: Response): Promise<Response> {
27+
if (body?.pwnedRecheckEnabled) {
28+
const status = this.getHibpKeyStatus()
29+
if (!status.valid) {
30+
throw new BadRequestException(status.reason || 'Clé SESAME_PASSWORD_HISTORY_HIBP_KEY invalide')
31+
}
32+
}
2333
const data = await this.passwdadmService.setPolicies(body);
2434

2535
return res.status(HttpStatus.OK).json({ data });
@@ -38,4 +48,34 @@ export class PasswdadmController {
3848

3949
return res.status(HttpStatus.OK).json({ data });
4050
}
51+
52+
@Get('hibp-keystatus')
53+
@UseRoles({
54+
resource: '/settings/passwdadm',
55+
action: AC_ACTIONS.READ,
56+
possession: AC_DEFAULT_POSSESSION,
57+
})
58+
@ApiOperation({ summary: 'Retourne le statut de validité de la clé de chiffrement HIBP' })
59+
@ApiResponse({ status: HttpStatus.OK })
60+
public async hibpKeyStatus(@Res() res: Response): Promise<Response> {
61+
return res.status(HttpStatus.OK).json({ data: this.getHibpKeyStatus() })
62+
}
63+
64+
private getHibpKeyStatus(): { valid: boolean; reason: string | null } {
65+
const raw = (this.configService.get<string>('SESAME_PASSWORD_HISTORY_HIBP_KEY') || '').trim()
66+
if (!raw) {
67+
return { valid: false, reason: 'Clé manquante (SESAME_PASSWORD_HISTORY_HIBP_KEY)' }
68+
}
69+
70+
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
71+
return { valid: true, reason: null }
72+
}
73+
74+
const buf = Buffer.from(raw, 'base64')
75+
if (buf.length !== 32) {
76+
return { valid: false, reason: 'Clé invalide (base64) : doit décoder en 32 bytes' }
77+
}
78+
79+
return { valid: true, reason: null }
80+
}
4181
}

apps/web/src/pages/settings/password-policy.vue

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,15 @@
8383
hint="Utilise l'API de pwned pour vérifier si le mot de passe a été compromis dans une fuite de données"
8484
)
8585
q-toggle.col-12.col-sm-6.col-md-4.col-lg-3(
86-
:disable='!hasPermission("/settings/passwdadm", "update")'
86+
:disable='!hasPermission("/settings/passwdadm", "update") || !hibpKeyStatus.valid'
8787
dense
8888
v-model="payload.pwnedRecheckEnabled"
8989
color="teal"
90-
label="Re-check HIBP (tâche planifiée)"
91-
hint="Active le re-check HIBP (cron) via une empreinte SHA-1 chiffrée stockée dans l'historique des mots de passe"
90+
label="Stockage des empreintes HIBP (Pwned Passwords)"
91+
hint="Active le stockage des empreintes SHA-1 chiffrées dans l'historique des mots de passe"
9292
)
93+
q-tooltip.text-body2(anchor="top middle" self="bottom middle" v-if="!hibpKeyStatus.valid")
94+
span(v-text="hibpKeyStatus.reason || 'Clé SESAME_PASSWORD_HISTORY_HIBP_KEY invalide'")
9395
q-toggle.col-12.col-sm-6.col-md-4.col-lg-3(
9496
:disable='!hasPermission("/settings/passwdadm", "update")'
9597
dense
@@ -137,6 +139,17 @@
137139
hint="Re-check uniquement si la dernière vérification est plus ancienne que ce délai"
138140
dense
139141
)
142+
q-select.col-12.col-sm-6.col-md-5.col-lg-4(
143+
:disable='!hasPermission("/settings/passwdadm", "update")'
144+
outlined
145+
dense
146+
emit-value
147+
map-options
148+
v-model="payload.pwnedRecheckAction"
149+
:options="pwnedActions"
150+
label="Action si mot de passe compromis lors du re-check HIBP"
151+
hint="« Notifier » avertit l'utilisateur sans le bloquer. « Expirer » force un changement au prochain usage."
152+
)
140153
q-separator.q-my-lg
141154
.row.q-col-gutter-md
142155
q-input.col-12.col-md-6(
@@ -185,6 +198,7 @@ type PasswordPolicySettings = {
185198
checkPwned: boolean
186199
pwnedRecheckEnabled: boolean
187200
pwnedRecheckMaxAgeSeconds: number
201+
pwnedRecheckAction: 'none' | 'notify' | 'expire'
188202
resetBySms: boolean
189203
emailAttribute: string
190204
mobileAttribute: string
@@ -204,6 +218,12 @@ export default defineComponent({
204218
const { handleError } = useErrorHandling()
205219
const { hasPermission } = useAccessControl()
206220
221+
const pwnedActions = [
222+
{ label: 'Ne rien faire', value: 'none' },
223+
{ label: "Notifier l'utilisateur", value: 'notify' },
224+
{ label: 'Expirer le mot de passe', value: 'expire' },
225+
] as const
226+
207227
const payload = ref({
208228
len: 8,
209229
hasUpperCase: 0,
@@ -213,6 +233,7 @@ export default defineComponent({
213233
checkPwned: false,
214234
pwnedRecheckEnabled: false,
215235
pwnedRecheckMaxAgeSeconds: 60 * 60 * 24 * 7,
236+
pwnedRecheckAction: 'none',
216237
resetBySms: false,
217238
emailAttribute: '',
218239
mobileAttribute: '',
@@ -223,6 +244,7 @@ export default defineComponent({
223244
initTokenTTL: 0,
224245
})
225246
const validations = ref({} as Record<string, any>)
247+
const hibpKeyStatus = ref<{ valid: boolean; reason: string | null }>({ valid: true, reason: null })
226248
227249
const {
228250
data: result,
@@ -232,6 +254,13 @@ export default defineComponent({
232254
} = await useHttp<{ data: PasswordPolicySettings }>(`/settings/passwdadm/getpolicies`, {
233255
method: 'GET',
234256
})
257+
258+
const { data: keyStatusResult } = await useHttp<{ data: { valid: boolean; reason: string | null } }>(`/settings/passwdadm/hibp-keystatus`, {
259+
method: 'GET',
260+
})
261+
if (keyStatusResult.value?.data) {
262+
hibpKeyStatus.value = keyStatusResult.value.data
263+
}
235264
if (error.value) {
236265
handleError({
237266
error: error.value,
@@ -242,8 +271,14 @@ export default defineComponent({
242271
validations.value = {}
243272
}
244273
274+
if (!hibpKeyStatus.value.valid) {
275+
payload.value.pwnedRecheckEnabled = false
276+
}
277+
245278
return {
246279
payload,
280+
pwnedActions,
281+
hibpKeyStatus,
247282
handleError,
248283
pending,
249284
refresh,

0 commit comments

Comments
 (0)