From 15123f691c51ec6254adc875ce104b5ed27786f6 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 23 Apr 2026 18:17:39 +0200 Subject: [PATCH] feat(auth): #127 forgot-password + reset-password API (BDD + mail) Made-with: Cursor --- backend/src/entities/users.entity.ts | 7 ++ backend/src/modules/mail/mail.service.spec.ts | 16 +++ backend/src/modules/mail/mail.service.ts | 27 ++++ .../modules/mail/templates/password-reset.hbs | 12 ++ .../src/routes/auth/auth.controller.spec.ts | 37 ++++++ backend/src/routes/auth/auth.controller.ts | 47 ++++++- backend/src/routes/auth/auth.service.spec.ts | 79 +++++++++++- backend/src/routes/auth/auth.service.ts | 118 ++++++++++++++++++ .../routes/auth/dto/forgot-password.dto.ts | 20 +++ .../src/routes/auth/dto/reset-password.dto.ts | 25 ++++ database/BDD.sql | 14 +++ 11 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 backend/src/modules/mail/templates/password-reset.hbs create mode 100644 backend/src/routes/auth/dto/forgot-password.dto.ts create mode 100644 backend/src/routes/auth/dto/reset-password.dto.ts diff --git a/backend/src/entities/users.entity.ts b/backend/src/entities/users.entity.ts index 6382e4b..398af51 100644 --- a/backend/src/entities/users.entity.ts +++ b/backend/src/entities/users.entity.ts @@ -119,6 +119,13 @@ export class Users { @Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' }) token_creation_mdp_expire_le?: Date; + /** Ticket #127 — réinitialisation mot de passe oublié (distinct de token_creation_mdp / inscription) */ + @Column({ nullable: true, name: 'password_reset_token', length: 255 }) + password_reset_token?: string; + + @Column({ type: 'timestamptz', nullable: true, name: 'password_reset_expires' }) + password_reset_expires?: Date; + /** Token pour reprise après refus (lien email), ticket #110 */ @Column({ nullable: true, name: 'token_reprise', length: 255 }) token_reprise?: string; diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 03eb5b0..4f47a6a 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -68,4 +68,20 @@ describe('MailService (ticket #28)', () => { expect(html).toContain('2025-000001'); expect(html).toContain(`https://app.test/create-password?token=${encodeURIComponent(token)}`); }); + + it('sendPasswordResetEmail utilise Handlebars + lien /reset-password', async () => { + const token = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + await service.sendPasswordResetEmail({ + email: 'u@test.fr', + prenom: 'Marie', + nom: 'Curie', + token, + }); + expect(sendEmailSpy).toHaveBeenCalledTimes(1); + const [, subject, html] = sendEmailSpy.mock.calls[0]; + expect(subject).toContain('Réinitialisation'); + expect(html).toContain('Marie'); + expect(html).toContain(`https://app.test/reset-password?token=${encodeURIComponent(token)}`); + expect(html).not.toContain('create-password'); + }); }); diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 79521c8..b2baa85 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -238,4 +238,31 @@ export class MailService { await this.sendEmail(to, subject, html); } + + /** + * Ticket #127 — lien application `/reset-password?token=` (pas create-password). + */ + async sendPasswordResetEmail(recipient: { + email: string; + prenom: string; + nom: string; + token: string; + }): Promise { + const appName = this.configService.get('app_name', "P'titsPas"); + const appUrl = (this.configService.get('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, ''); + const resetPasswordUrl = `${appUrl}/reset-password?token=${encodeURIComponent(recipient.token)}`; + + const filePath = join(__dirname, 'templates', 'password-reset.hbs'); + const source = readFileSync(filePath, 'utf8'); + const compiled = Handlebars.compile(source); + const html = compiled({ + prenom: recipient.prenom || '', + nom: recipient.nom || '', + appName, + resetPasswordUrl, + }) as string; + + const subject = `${appName} — Réinitialisation de votre mot de passe`; + await this.sendEmail(recipient.email, subject, html); + } } diff --git a/backend/src/modules/mail/templates/password-reset.hbs b/backend/src/modules/mail/templates/password-reset.hbs new file mode 100644 index 0000000..68401d0 --- /dev/null +++ b/backend/src/modules/mail/templates/password-reset.hbs @@ -0,0 +1,12 @@ +
+

Bonjour {{prenom}} {{nom}},

+

Vous avez demandé à réinitialiser votre mot de passe sur {{appName}}.

+

Cliquez sur le bouton ci-dessous pour en choisir un nouveau. Ce lien est valable une durée limitée.

+ +

Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :
{{{resetPasswordUrl}}}

+

Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet e-mail.

+
+

Cet email a été envoyé automatiquement par {{appName}}. Merci de ne pas y répondre.

+
diff --git a/backend/src/routes/auth/auth.controller.spec.ts b/backend/src/routes/auth/auth.controller.spec.ts index e1b11aa..cad4c47 100644 --- a/backend/src/routes/auth/auth.controller.spec.ts +++ b/backend/src/routes/auth/auth.controller.spec.ts @@ -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'); + }); }); diff --git a/backend/src/routes/auth/auth.controller.ts b/backend/src/routes/auth/auth.controller.ts index 2d7942d..7e2c6d3 100644 --- a/backend/src/routes/auth/auth.controller.ts +++ b/backend/src/routes/auth/auth.controller.ts @@ -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 l’e-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') diff --git a/backend/src/routes/auth/auth.service.spec.ts b/backend/src/routes/auth/auth.service.spec.ts index 03812b4..0361563 100644 --- a/backend/src/routes/auth/auth.service.spec.ts +++ b/backend/src/routes/auth/auth.service.spec.ts @@ -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(); + }); + }); }); diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 506c22c..b291009 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -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 ; n’utilise 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('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 diff --git a/backend/src/routes/auth/dto/forgot-password.dto.ts b/backend/src/routes/auth/dto/forgot-password.dto.ts new file mode 100644 index 0000000..dcd15ce --- /dev/null +++ b/backend/src/routes/auth/dto/forgot-password.dto.ts @@ -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; +} diff --git a/backend/src/routes/auth/dto/reset-password.dto.ts b/backend/src/routes/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..47844d0 --- /dev/null +++ b/backend/src/routes/auth/dto/reset-password.dto.ts @@ -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; +} diff --git a/database/BDD.sql b/database/BDD.sql index da1a210..b7cd431 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -62,6 +62,9 @@ CREATE TABLE utilisateurs ( date_consentement_photo TIMESTAMPTZ, token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation token_creation_mdp_expire_le TIMESTAMPTZ, -- Expiration 7 jours + -- Ticket #127 : réinitialisation mot de passe oublié (distinct de token_creation_mdp) + password_reset_token VARCHAR(255) NULL, + password_reset_expires TIMESTAMPTZ NULL, changement_mdp_obligatoire BOOLEAN DEFAULT false, cree_le TIMESTAMPTZ DEFAULT now(), modifie_le TIMESTAMPTZ DEFAULT now(), @@ -76,6 +79,10 @@ CREATE INDEX idx_utilisateurs_token_creation_mdp ON utilisateurs(token_creation_mdp) WHERE token_creation_mdp IS NOT NULL; +CREATE INDEX idx_utilisateurs_password_reset_token + ON utilisateurs(password_reset_token) + WHERE password_reset_token IS NOT NULL; + -- ========================================================== -- Table : assistantes_maternelles -- ========================================================== @@ -411,6 +418,13 @@ ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NUL ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL; CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL; +-- ========================================================== +-- Ticket #127 : Mot de passe oublié (token dédié, ≠ inscription) +-- ========================================================== +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255) NULL; +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_expires TIMESTAMPTZ NULL; +CREATE INDEX IF NOT EXISTS idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL; + -- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes) ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL; ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;