feat(auth): #127 forgot-password + reset-password API (BDD + mail)

Made-with: Cursor
This commit is contained in:
2026-04-23 18:17:39 +02:00
parent e51e625d93
commit 15123f691c
11 changed files with 399 additions and 3 deletions
+118
View File
@@ -249,6 +249,124 @@ export class AuthService {
};
}
/** Réponse unique pour POST forgot-password (anti-énumération), ticket #127 */
private static readonly FORGOT_PASSWORD_GENERIC_MESSAGE =
'Si cette adresse est associée à un compte pour lequel un mot de passe existe, vous recevrez un e-mail avec les instructions.';
/**
* Ticket #127 — demande de réinitialisation (e-mail avec lien /reset-password).
* Toujours la même réponse 200 ; nutilise pas token_creation_mdp (#118 inscription).
*/
async requestPasswordReset(rawEmail: string): Promise<{ message: string }> {
const generic = { message: AuthService.FORGOT_PASSWORD_GENERIC_MESSAGE };
const email = (rawEmail ?? '').trim().toLowerCase();
if (!email) {
return generic;
}
const user = await this.usersRepo
.createQueryBuilder('u')
.where('LOWER(TRIM(u.email)) = :email', { email })
.select(['u.id', 'u.email', 'u.prenom', 'u.nom', 'u.password'])
.getOne();
if (!user?.password) {
return generic;
}
const expiryDaysRaw = this.appConfigService.get<number>('password_reset_token_expiry_days', 7);
const expiryDays = Number(expiryDaysRaw) > 0 ? Number(expiryDaysRaw) : 7;
const expires = new Date();
expires.setDate(expires.getDate() + expiryDays);
const token = crypto.randomUUID();
await this.usersRepo.update(
{ id: user.id },
{
password_reset_token: token,
password_reset_expires: expires,
},
);
try {
await this.mailService.sendPasswordResetEmail({
email: user.email,
prenom: user.prenom ?? '',
nom: user.nom ?? '',
token,
});
} catch (err) {
this.logger.error(
'[requestPasswordReset] Échec envoi e-mail (réponse inchangée pour anti-énumération)',
err,
);
}
return generic;
}
/**
* Ticket #127 — consomme password_reset_token (distinct de create-password / token_creation_mdp).
*/
async resetPasswordWithResetToken(token: string, plainPassword: string): Promise<{ message: string }> {
const cleanedToken = token?.trim();
if (!cleanedToken) {
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
const existingUser = await this.usersRepo.findOne({
where: { password_reset_token: cleanedToken },
select: ['id', 'password', 'password_reset_expires'],
});
if (!existingUser?.password) {
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
if (!existingUser.password_reset_expires || existingUser.password_reset_expires <= new Date()) {
await this.usersRepo
.createQueryBuilder()
.update(Users)
.set({
password_reset_token: () => 'NULL',
password_reset_expires: () => 'NULL',
})
.where('id = :id', { id: existingUser.id })
.execute();
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
const sel = await bcrypt.genSalt(12);
const hashedPassword = await bcrypt.hash(plainPassword, sel);
const updateResult = await this.usersRepo
.createQueryBuilder()
.update(Users)
.set({
password: hashedPassword,
password_reset_token: () => 'NULL',
password_reset_expires: () => 'NULL',
changement_mdp_obligatoire: false,
})
.where('password_reset_token = :token', { token: cleanedToken })
.andWhere('password_reset_expires > NOW()')
.execute();
if (!updateResult.affected) {
this.logger.warn(
'[resetPasswordWithResetToken] Token non consommé (course concurrente ou token invalidé)',
);
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
this.logger.log('[resetPasswordWithResetToken] Mot de passe réinitialisé avec succès');
return {
message: 'Mot de passe mis à jour. Vous pouvez vous connecter.',
};
}
/**
* Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM()
* @deprecated