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
@@ -9,6 +9,8 @@ describe('AuthController', () => {
const authServiceMock = {
verifyCreatePasswordToken: jest.fn(),
createPasswordWithToken: jest.fn(),
requestPasswordReset: jest.fn(),
resetPasswordWithResetToken: jest.fn(),
};
beforeEach(async () => {
@@ -70,4 +72,39 @@ describe('AuthController', () => {
});
expect(authServiceMock.createPasswordWithToken).toHaveBeenCalledWith('tok-123', 'Password1');
});
it('forgot-password délègue au service et renvoie le message générique', async () => {
authServiceMock.requestPasswordReset.mockResolvedValue({
message: 'Si cette adresse est associée…',
});
await expect(controller.forgotPassword({ email: 'user@test.fr' })).resolves.toEqual({
message: 'Si cette adresse est associée…',
});
expect(authServiceMock.requestPasswordReset).toHaveBeenCalledWith('user@test.fr');
});
it('rejects reset-password when confirmation mismatches', async () => {
await expect(
controller.resetPassword({
token: 'tok',
password: 'Password1',
password_confirmation: 'Password2',
}),
).rejects.toThrow(BadRequestException);
expect(authServiceMock.resetPasswordWithResetToken).not.toHaveBeenCalled();
});
it('calls reset-password when payload is valid', async () => {
authServiceMock.resetPasswordWithResetToken.mockResolvedValue({
message: 'Mot de passe mis à jour.',
});
await expect(
controller.resetPassword({
token: 'tok-123',
password: 'Password1',
password_confirmation: 'Password1',
}),
).resolves.toEqual({ message: 'Mot de passe mis à jour.' });
expect(authServiceMock.resetPasswordWithResetToken).toHaveBeenCalledWith('tok-123', 'Password1');
});
});
+46 -1
View File
@@ -1,4 +1,17 @@
import { Body, Controller, Get, Patch, Post, Query, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Patch,
Post,
Query,
Req,
UnauthorizedException,
BadRequestException,
UseGuards,
} from '@nestjs/common';
import { LoginDto } from './dto/login.dto';
import { AuthService } from './auth.service';
import { Public } from 'src/common/decorators/public.decorator';
@@ -8,6 +21,8 @@ import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
import { RegisterAmResponseDto } from './dto/register-am-response.dto';
import { ChangePasswordRequiredDto } from './dto/change-password.dto';
import { CreatePasswordDto } from './dto/create-password.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from 'src/common/guards/auth.guard';
import type { Request } from 'express';
@@ -133,6 +148,36 @@ export class AuthController {
return this.authService.createPasswordWithToken(dto.token, dto.password);
}
@Public()
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Demande de réinitialisation du mot de passe (ticket #127)',
description:
'Réponse identique que le-mail existe ou non (anti-énumération). Si un compte avec mot de passe existe, un e-mail avec lien /reset-password est envoyé.',
})
@ApiResponse({ status: 200, description: 'Message générique' })
async forgotPassword(@Body() dto: ForgotPasswordDto) {
return this.authService.requestPasswordReset(dto.email ?? '');
}
@Public()
@Post('reset-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Réinitialiser le mot de passe via token e-mail (ticket #127)',
description: 'Distinct de POST /auth/create-password (inscription). Utilise password_reset_token.',
})
@ApiResponse({ status: 200, description: 'Mot de passe mis à jour' })
@ApiResponse({ status: 400, description: 'Confirmation ou règles de mot de passe' })
@ApiResponse({ status: 404, description: 'Token invalide, expiré, ou déjà utilisé' })
async resetPassword(@Body() dto: ResetPasswordDto) {
if (dto.password !== dto.password_confirmation) {
throw new BadRequestException('Les mots de passe ne correspondent pas');
}
return this.authService.resetPasswordWithResetToken(dto.token, dto.password);
}
@Get('me')
@UseGuards(AuthGuard)
@ApiBearerAuth('access-token')
+77 -2
View File
@@ -18,18 +18,38 @@ describe('AuthService (#118 create-password API)', () => {
const usersRepoMock = {
findOne: jest.fn(),
createQueryBuilder: jest.fn(),
update: jest.fn(),
};
const mailServiceMock = {
sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined),
};
const appConfigMock = {
get: jest.fn().mockReturnValue(7),
};
beforeEach(async () => {
jest.clearAllMocks();
const qbChain: any = {};
qbChain.where = jest.fn().mockReturnValue(qbChain);
qbChain.select = jest.fn().mockReturnValue(qbChain);
qbChain.getOne = jest.fn().mockResolvedValue(null);
qbChain.update = jest.fn().mockReturnValue(qbChain);
qbChain.set = jest.fn().mockReturnValue(qbChain);
qbChain.andWhere = jest.fn().mockReturnValue(qbChain);
qbChain.execute = jest.fn().mockResolvedValue({ affected: 0 });
usersRepoMock.createQueryBuilder.mockReturnValue(qbChain);
usersRepoMock.update.mockResolvedValue({ affected: 1, raw: [] });
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: UserService, useValue: {} },
{ provide: JwtService, useValue: {} },
{ provide: ConfigService, useValue: { get: jest.fn() } },
{ provide: AppConfigService, useValue: {} },
{ provide: MailService, useValue: {} },
{ provide: AppConfigService, useValue: appConfigMock },
{ provide: MailService, useValue: mailServiceMock },
{ provide: NumeroDossierService, useValue: {} },
{ provide: getRepositoryToken(Parents), useValue: {} },
{ provide: getRepositoryToken(Users), useValue: usersRepoMock },
@@ -61,4 +81,59 @@ describe('AuthService (#118 create-password API)', () => {
message: 'Token valide',
});
});
describe('#127 forgot / reset password', () => {
it('requestPasswordReset returns generic message when email unknown', async () => {
const r = await service.requestPasswordReset('unknown@test.fr');
expect(r.message).toContain('Si cette adresse');
expect(mailServiceMock.sendPasswordResetEmail).not.toHaveBeenCalled();
});
it('requestPasswordReset sends mail when user has password', async () => {
usersRepoMock.createQueryBuilder.mockReturnValueOnce({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
id: 'u1',
email: 'a@test.fr',
prenom: 'A',
nom: 'B',
password: 'hashed',
}),
});
const r = await service.requestPasswordReset('a@test.fr');
expect(r.message).toContain('Si cette adresse');
expect(usersRepoMock.update).toHaveBeenCalled();
expect(mailServiceMock.sendPasswordResetEmail).toHaveBeenCalledWith(
expect.objectContaining({ email: 'a@test.fr', token: expect.any(String) }),
);
});
it('resetPasswordWithResetToken throws when token unknown', async () => {
usersRepoMock.findOne.mockResolvedValue(null);
await expect(service.resetPasswordWithResetToken('bad', 'Password1')).rejects.toThrow(
NotFoundException,
);
});
it('resetPasswordWithResetToken succeeds when update affects row', async () => {
usersRepoMock.findOne.mockResolvedValue({
id: 'u1',
password: 'oldhash',
password_reset_expires: new Date(Date.now() + 60_000),
});
const exec = jest.fn().mockResolvedValue({ affected: 1 });
const chain: any = {};
chain.update = jest.fn().mockReturnValue(chain);
chain.set = jest.fn().mockReturnValue(chain);
chain.where = jest.fn().mockReturnValue(chain);
chain.andWhere = jest.fn().mockReturnValue(chain);
chain.execute = exec;
usersRepoMock.createQueryBuilder.mockReturnValueOnce(chain);
const r = await service.resetPasswordWithResetToken('good-token', 'Password1');
expect(r.message.toLowerCase()).toContain('mis à jour');
expect(exec).toHaveBeenCalled();
});
});
});
+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
@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsOptional, IsString, MaxLength } from 'class-validator';
/**
* Ticket #127 — pas de @IsEmail / @MinLength stricts : éviter 400 sur saisies vides ou bizarres ;
* le service renvoie toujours la même réponse 200 (anti-énumération).
*/
export class ForgotPasswordDto {
@ApiProperty({
example: 'parent@example.com',
required: false,
description: 'E-mail (trim + lowercase via transform). Absent ou vide → même réponse générique.',
})
@IsOptional()
@IsString()
@MaxLength(320)
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : ''))
email?: string;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength, Matches } from 'class-validator';
/** Ticket #127 — mêmes règles que [CreatePasswordDto] (inscription #118). */
export class ResetPasswordDto {
@ApiProperty({ description: 'Token UUID reçu par e-mail (lien reset-password)' })
@IsString()
@MinLength(1, { message: 'Token manquant' })
token: string;
@ApiProperty({
description: 'Nouveau mot de passe (min 8 caractères, 1 majuscule, 1 chiffre)',
minLength: 8,
})
@IsString()
@MinLength(8, { message: 'Le mot de passe doit contenir au moins 8 caractères' })
@Matches(/^(?=.*[A-Z])(?=.*\d)/, {
message: 'Le mot de passe doit contenir au moins une majuscule et un chiffre',
})
password: string;
@ApiProperty({ description: 'Confirmation du nouveau mot de passe' })
@IsString()
password_confirmation: string;
}