Skip to content

Commit aaaf5a2

Browse files
committed
feat: integrate PasswordHistory module and enhance password management services
- Added the `PasswordHistoryModule` to the Management, Identities, and Passwd modules for improved password history tracking. - Updated `IdentitiesForcepasswordService` to utilize `PasswordHistoryService` for enforcing password reuse policies. - Enhanced `PasswdService` to record password changes and assert non-reuse of passwords using the new service. - Introduced a new command `IdentitiesPwnedCommand` for checking compromised passwords against the HIBP API. - Updated password policy DTO to include settings for password history management and HIBP recheck configurations. - Added UI components for managing password history settings in the web application.
1 parent 130c73a commit aaaf5a2

File tree

15 files changed

+683
-4
lines changed

15 files changed

+683
-4
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
tasks:
2+
- name: "identities-pwned-recheck"
3+
description: "Re-check HIBP (pwned passwords) for stored password history fingerprints"
4+
enabled: false
5+
schedule: "0 3 * * *" # Tous les jours à 03:00
6+
handler: "identities-pwned-recheck"
7+
options:
8+
limit: 500
9+

apps/api/src/management/identities/identities-forcepassword.service.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import {AbstractIdentitiesService} from '~/management/identities/abstract-identities.service';
22
import {Identities} from '~/management/identities/_schemas/identities.schema';
3-
import {BadRequestException, HttpException, Injectable} from '@nestjs/common';
3+
import { BadRequestException, HttpException, Inject, Injectable } from '@nestjs/common';
44
import {DataStatusEnum} from '~/management/identities/_enums/data-status';
55
import {ActionType} from "~/core/backends/_enum/action-type.enum";
6+
import { PasswordHistoryService } from '~/management/password-history/password-history.service'
67

78

89
@Injectable()
910
export class IdentitiesForcepasswordService extends AbstractIdentitiesService {
11+
@Inject(PasswordHistoryService)
12+
private readonly passwordHistory: PasswordHistoryService
1013

1114
public async forcePassword(id: string, newPassword: string) {
1215
//recherche de l'identité
@@ -30,6 +33,7 @@ export class IdentitiesForcepasswordService extends AbstractIdentitiesService {
3033
statusCode: 400,
3134
});
3235
}
36+
await this.passwordHistory.assertNotReused(identity._id, newPassword)
3337
//ok on envoie le changement de mdp
3438
try{
3539
const [_, response] = await this.backends.executeJob(
@@ -45,6 +49,7 @@ export class IdentitiesForcepasswordService extends AbstractIdentitiesService {
4549
},
4650
);
4751
if (response?.status === 0) {
52+
await this.passwordHistory.recordPassword(identity._id, newPassword, 'force')
4853
//activation de l'identité
4954
await this.activation(id,DataStatusEnum.ACTIVE)
5055
return [_, response];

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

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { Identities } from "~/management/identities/_schemas/identities.schema";
66
import { IdentitiesUpsertService } from "~/management/identities/identities-upsert.service";
77
import { IdentitiesDoublonService } from "~/management/identities/identities-doublon.service";
88
import { Types } from "mongoose";
9+
import axios from 'axios'
10+
import { PasswordHistoryService } from '~/management/password-history/password-history.service'
11+
import { PasswdadmService } from '~/settings/passwdadm.service'
912

1013

1114
@SubCommand({ name: 'fingerprint' })
@@ -88,7 +91,118 @@ export class IdentitiesCancelFusionCommand extends CommandRunner {
8891
}
8992
}
9093

91-
@Command({ name: 'identities', arguments: '<task>', subCommands: [IdentitiesFingerprintCommand, IdentitiesCancelFusionCommand] })
94+
@SubCommand({ name: 'pwned' })
95+
export class IdentitiesPwnedCommand extends CommandRunner {
96+
private readonly logger = new Logger(IdentitiesPwnedCommand.name)
97+
98+
public constructor(
99+
protected moduleRef: ModuleRef,
100+
) {
101+
super()
102+
}
103+
104+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
105+
async run(inputs: string[], options: any): Promise<void> {
106+
const subTask = inputs?.[0]
107+
if (subTask !== 'recheck') {
108+
console.error('Usage: yarn run console identities pwned recheck [--limit=500]')
109+
return
110+
}
111+
112+
// `ModuleRef.resolve()` ne fonctionne que pour les providers transient / request-scoped.
113+
// Ici on a des services singleton, on utilise donc `get()`.
114+
const passwordHistory = this.moduleRef.get(PasswordHistoryService, { strict: false })
115+
const passwdadm = this.moduleRef.get(PasswdadmService, { strict: false })
116+
117+
const policies: any = await passwdadm.getPolicies()
118+
if (!policies?.pwnedRecheckEnabled) {
119+
this.logger.warn('HIBP recheck is disabled by settings (pwnedRecheckEnabled=false).')
120+
return
121+
}
122+
123+
const maxAgeSeconds = Number(options?.maxAgeSeconds || policies?.pwnedRecheckMaxAgeSeconds || 0)
124+
const limit = Math.max(Number(options?.limit || 500), 1)
125+
126+
const cutoff = Number.isFinite(maxAgeSeconds) && maxAgeSeconds > 0 ? new Date(Date.now() - maxAgeSeconds * 1000) : null
127+
this.logger.debug(
128+
`HIBP recheck policy: enabled=true maxAgeSeconds=${Number.isFinite(maxAgeSeconds) ? maxAgeSeconds : 'NaN'} cutoff=${cutoff ? cutoff.toISOString() : 'none'} limit=${limit}`,
129+
)
130+
131+
const filter: any = {
132+
hibpSha1Enc: { $ne: null },
133+
}
134+
if (cutoff) {
135+
filter.$or = [{ hibpLastCheckAt: null }, { hibpLastCheckAt: { $lt: cutoff } }]
136+
} else {
137+
filter.$or = [{ hibpLastCheckAt: null }]
138+
}
139+
140+
const candidates = await passwordHistory.model
141+
.find(filter)
142+
.sort({ hibpLastCheckAt: 1, createdAt: -1 })
143+
.limit(limit)
144+
.lean()
145+
146+
const totalWithEnc = await passwordHistory.model.countDocuments({ hibpSha1Enc: { $ne: null } })
147+
const totalNeverCheckedWithEnc = await passwordHistory.model.countDocuments({ hibpSha1Enc: { $ne: null }, hibpLastCheckAt: null })
148+
this.logger.log(`HIBP recheck candidates: ${candidates.length} (withEnc=${totalWithEnc}, neverCheckedWithEnc=${totalNeverCheckedWithEnc})`)
149+
150+
for (const entry of candidates) {
151+
const enc = entry?.hibpSha1Enc
152+
if (!enc) continue
153+
154+
let sha1: string
155+
try {
156+
sha1 = passwordHistory.decryptHibpSha1(enc)
157+
} catch (e) {
158+
this.logger.warn(`Failed to decrypt hibpSha1Enc for history <${entry._id}>: ${e?.message || e}`)
159+
await passwordHistory.model.updateOne(
160+
{ _id: entry._id },
161+
{ $set: { hibpLastCheckAt: new Date(), hibpPwnCount: null } },
162+
)
163+
continue
164+
}
165+
166+
const prefix = sha1.slice(0, 5)
167+
const suffix = sha1.slice(5)
168+
169+
try {
170+
const { data } = await axios.get<string>(`https://api.pwnedpasswords.com/range/${prefix}`, {
171+
responseType: 'text',
172+
timeout: 10_000,
173+
headers: {
174+
'User-Agent': 'sesame-orchestrator',
175+
'Add-Padding': 'true',
176+
},
177+
})
178+
179+
let pwnCount = 0
180+
const lines = String(data || '').split('\n')
181+
for (const line of lines) {
182+
const [s, countRaw] = line.trim().split(':')
183+
if (!s || !countRaw) continue
184+
if (s.toUpperCase() === suffix.toUpperCase()) {
185+
pwnCount = parseInt(countRaw, 10) || 0
186+
break
187+
}
188+
}
189+
190+
await passwordHistory.model.updateOne(
191+
{ _id: entry._id },
192+
{ $set: { hibpLastCheckAt: new Date(), hibpPwnCount: pwnCount } },
193+
)
194+
195+
if (pwnCount > 0) {
196+
this.logger.warn(`PWNED password detected: history=<${entry._id}> identityId=<${entry.identityId}> count=${pwnCount}`)
197+
}
198+
} catch (e) {
199+
this.logger.warn(`HIBP range call failed for history <${entry._id}>: ${e?.message || e}`)
200+
}
201+
}
202+
}
203+
}
204+
205+
@Command({ name: 'identities', arguments: '<task>', subCommands: [IdentitiesFingerprintCommand, IdentitiesCancelFusionCommand, IdentitiesPwnedCommand] })
92206
export class IdentitiesCommand extends CommandRunner {
93207
public constructor(protected moduleRef: ModuleRef) {
94208
super();

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { EnsureIdentitiesIndexMiddleware } from './_middlewares/ensure-identitie
2525
import { AgentsModule } from '~/core/agents/agents.module';
2626
import { useOnCli } from '~/_common/functions/is-cli';
2727
import { IdentitiesCommand } from '~/management/identities/identities.command';
28+
import { PasswordHistoryModule } from '~/management/password-history/password-history.module';
2829

2930

3031
@Module({
@@ -40,6 +41,7 @@ import { IdentitiesCommand } from '~/management/identities/identities.command';
4041
FilestorageModule,
4142
forwardRef(() => BackendsModule),
4243
SettingsModule,
44+
PasswordHistoryModule,
4345
AgentsModule,
4446
],
4547
providers: [

apps/api/src/management/management.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { RouterModule } from '@nestjs/core';
55
import { IdentitiesModule } from './identities/identities.module';
66
import { PasswdModule } from './passwd/passwd.module';
77
import { LifecycleModule } from './lifecycle/lifecycle.module';
8+
import { PasswordHistoryModule } from './password-history/password-history.module';
89

910
@Module({
10-
imports: [IdentitiesModule, PasswdModule, LifecycleModule],
11+
imports: [IdentitiesModule, PasswdModule, LifecycleModule, PasswordHistoryModule],
1112
providers: [ManagementService],
1213
controllers: [ManagementController],
1314
})

apps/api/src/management/passwd/passwd.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import { PasswdController } from './passwd.controller';
44
import { BackendsModule } from '~/core/backends/backends.module';
55
import { IdentitiesModule } from '../identities/identities.module';
66
import { SettingsModule } from '~/settings/settings.module';
7+
import { PasswordHistoryModule } from '~/management/password-history/password-history.module';
78

89
@Module({
9-
imports: [BackendsModule, IdentitiesModule, SettingsModule],
10+
imports: [BackendsModule, IdentitiesModule, SettingsModule, PasswordHistoryModule],
1011
controllers: [PasswdController],
1112
providers: [PasswdService],
1213
})

apps/api/src/management/passwd/passwd.service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { InitStatesEnum } from '~/management/identities/_enums/init-state.enum';
3333
import { MailadmService } from '~/settings/mailadm.service';
3434
import { DataStatusEnum } from "~/management/identities/_enums/data-status";
3535
import { SentMessageInfo } from 'nodemailer';
36+
import { PasswordHistoryService } from '~/management/password-history/password-history.service';
3637

3738
interface TokenData {
3839
k: string;
@@ -60,6 +61,7 @@ export class PasswdService extends AbstractService {
6061
private passwdadmService: PasswdadmService,
6162
private smsadmService: SmsadmService,
6263
private mailadmService: MailadmService,
64+
private readonly passwordHistory: PasswordHistoryService,
6365
@InjectRedis() private readonly redis: Redis,
6466
) {
6567
super();
@@ -232,6 +234,7 @@ export class PasswdService extends AbstractService {
232234
statusCode: 400,
233235
});
234236
}
237+
await this.passwordHistory.assertNotReused(identity._id, passwdDto.newPassword)
235238
//tout est ok en envoie au backend
236239
const result = await this.backends.executeJob(
237240
ActionType.IDENTITY_PASSWORD_CHANGE,
@@ -252,6 +255,7 @@ export class PasswdService extends AbstractService {
252255
);
253256
// on met actif l'identité
254257
await this.identities.model.updateOne({ _id: identity._id }, { dataStatus: DataStatusEnum.ACTIVE })
258+
await this.passwordHistory.recordPassword(identity._id, passwdDto.newPassword, 'change')
255259
return result;
256260
} catch (e) {
257261
let job = undefined;
@@ -375,6 +379,7 @@ export class PasswdService extends AbstractService {
375379
this.logger.log('dataToken :' + tokenData);
376380
try {
377381
const identity = (await this.identities.findOne({ 'inetOrgPerson.uid': tokenData.uid })) as Identities;
382+
await this.passwordHistory.assertNotReused(identity._id, data.newpassword)
378383
const [_, response] = await this.backends.executeJob(
379384
ActionType.IDENTITY_PASSWORD_RESET,
380385
identity._id,
@@ -395,6 +400,7 @@ export class PasswdService extends AbstractService {
395400
// on met actif l'identité
396401
identity.dataStatus = DataStatusEnum.ACTIVE;
397402
await identity.save()
403+
await this.passwordHistory.recordPassword(identity._id, data.newpassword, 'reset')
398404
return [_, response];
399405
}
400406
this.logger.error('Error from backend while reseting password by code');
@@ -417,6 +423,7 @@ export class PasswdService extends AbstractService {
417423
state: IdentityState.SYNCED,
418424
})) as Identities;
419425

426+
await this.passwordHistory.assertNotReused(identity._id, data.newPassword)
420427
const [_, response] = await this.backends.executeJob(
421428
ActionType.IDENTITY_PASSWORD_RESET,
422429
identity._id,
@@ -433,6 +440,7 @@ export class PasswdService extends AbstractService {
433440
if (response?.status === 0) {
434441
await this.redis.del(data.token);
435442
await this.setInitState(identity, InitStatesEnum.INITIALIZED);
443+
await this.passwordHistory.recordPassword(identity._id, data.newPassword, 'reset')
436444
return [_, response];
437445
}
438446

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
2+
import { Document, Types } from 'mongoose'
3+
4+
export type PasswordHistoryDocument = PasswordHistory & Document
5+
6+
@Schema({ versionKey: false, minimize: false, timestamps: true })
7+
export class PasswordHistory {
8+
@Prop({ type: Types.ObjectId, required: true, index: true })
9+
public identityId: Types.ObjectId
10+
11+
@Prop({ type: String, required: true })
12+
public passwordHash: string
13+
14+
/**
15+
* SHA-1(password) chiffré (AES-256-GCM), utilisé uniquement pour re-check HIBP en cron.
16+
*/
17+
@Prop({ type: String, required: false, default: null })
18+
public hibpSha1Enc?: string | null
19+
20+
@Prop({ type: Date, required: false, default: null })
21+
public hibpLastCheckAt?: Date | null
22+
23+
@Prop({ type: Number, required: false, default: null })
24+
public hibpPwnCount?: number | null
25+
26+
@Prop({ type: String, required: false, default: null })
27+
public source?: 'change' | 'reset' | 'force' | null
28+
29+
@Prop({ type: Date, required: false, default: null })
30+
public expiresAt?: Date | null
31+
}
32+
33+
export const PasswordHistorySchema = SchemaFactory.createForClass(PasswordHistory)
34+
.index({ identityId: 1, createdAt: -1 })
35+
.index({ expiresAt: 1 }, { expireAfterSeconds: 0 })
36+
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Controller, Get, HttpStatus, Param, Res } from '@nestjs/common'
2+
import { ApiParam, ApiTags } from '@nestjs/swagger'
3+
import { Response } from 'express'
4+
import { Types } from 'mongoose'
5+
import { ObjectIdValidationPipe } from '~/_common/pipes/object-id-validation.pipe'
6+
import { UseRoles } from '~/_common/decorators/use-roles.decorator'
7+
import { AC_ACTIONS, AC_DEFAULT_POSSESSION } from '~/_common/types/ac-types'
8+
import { PasswordHistoryService } from './password-history.service'
9+
import { PasswdadmService } from '~/settings/passwdadm.service'
10+
11+
@ApiTags('management/password-history')
12+
@Controller('password-history')
13+
export class PasswordHistoryController {
14+
public constructor(
15+
private readonly passwordHistoryService: PasswordHistoryService,
16+
private readonly passwdadmService: PasswdadmService,
17+
) {}
18+
19+
@Get(':identityId([0-9a-fA-F]{24})')
20+
@UseRoles({
21+
resource: '/management/password-history',
22+
action: AC_ACTIONS.READ,
23+
possession: AC_DEFAULT_POSSESSION,
24+
})
25+
@ApiParam({ name: 'identityId', type: String })
26+
public async listForIdentity(
27+
@Param('identityId', ObjectIdValidationPipe) identityId: Types.ObjectId,
28+
@Res() res: Response,
29+
): Promise<Response> {
30+
const policies: any = await this.passwdadmService.getPolicies()
31+
if (!policies?.passwordHistoryEnabled) {
32+
return res.status(HttpStatus.OK).json({
33+
statusCode: HttpStatus.OK,
34+
data: [],
35+
message: 'Historique des mots de passe désactivé par la politique',
36+
})
37+
}
38+
39+
const historyCount = Number(policies?.passwordHistoryCount || 0)
40+
const limit = Number.isFinite(historyCount) && historyCount > 0 ? historyCount : 0
41+
42+
const rows = limit
43+
? await this.passwordHistoryService.model
44+
.find({ identityId })
45+
.sort({ createdAt: -1 })
46+
.limit(limit)
47+
.select({
48+
_id: 1,
49+
createdAt: 1,
50+
expiresAt: 1,
51+
source: 1,
52+
hibpLastCheckAt: 1,
53+
hibpPwnCount: 1,
54+
hibpSha1Enc: 1,
55+
})
56+
.lean()
57+
: []
58+
59+
const safeRows = (rows || []).map((row: any) => {
60+
const hasHibpFingerprint = !!row?.hibpSha1Enc
61+
const { hibpSha1Enc: _hibpSha1Enc, ...rest } = row || {}
62+
return { ...rest, hasHibpFingerprint }
63+
})
64+
65+
return res.status(HttpStatus.OK).json({
66+
statusCode: HttpStatus.OK,
67+
data: safeRows,
68+
total: safeRows.length,
69+
})
70+
}
71+
}
72+

0 commit comments

Comments
 (0)