diff --git a/api/src/modules/auth/authentication/authentication.controller.ts b/api/src/modules/auth/authentication/authentication.controller.ts index cda10c04..f1ca8b83 100644 --- a/api/src/modules/auth/authentication/authentication.controller.ts +++ b/api/src/modules/auth/authentication/authentication.controller.ts @@ -42,14 +42,19 @@ export class AuthenticationController { @UseGuards(AuthGuard(ResetPassword)) @TsRestHandler(authContract.resetPassword) async resetPassword(@GetUser() user: User): Promise { - return tsRestHandler(authContract.resetPassword, async () => { - const userWithAccessToken = - await this.passwordRecovery.resetPassword(user); - return { - body: userWithAccessToken, - status: 201, - }; - }); + return tsRestHandler( + authContract.resetPassword, + async ({ body: { password } }) => { + const userWithAccessToken = await this.passwordRecovery.resetPassword( + user, + password, + ); + return { + body: userWithAccessToken, + status: 201, + }; + }, + ); } @TsRestHandler(authContract.requestPasswordRecovery) diff --git a/api/src/modules/auth/services/password-recovery.service.ts b/api/src/modules/auth/services/password-recovery.service.ts index fd637ed2..e87ec9e9 100644 --- a/api/src/modules/auth/services/password-recovery.service.ts +++ b/api/src/modules/auth/services/password-recovery.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotImplementedException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { UsersService } from '@api/modules/users/users.service'; import { JwtService } from '@nestjs/jwt'; import { AuthMailer } from '@api/modules/auth/services/auth.mailer'; @@ -7,6 +7,7 @@ import { PasswordRecoveryRequestedEvent } from '@api/modules/events/user-events/ import { ApiConfigService } from '@api/modules/config/app-config.service'; import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema'; import { User } from '@shared/entities/users/user.entity'; +import * as bcrypt from 'bcrypt'; @Injectable() export class PasswordRecoveryService { @@ -40,7 +41,8 @@ export class PasswordRecoveryService { this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, user.id)); } - async resetPassword(user: User): Promise { - throw new NotImplementedException(); + async resetPassword(user: User, newPassword: string): Promise { + const newHashedPassword = await bcrypt.hash(newPassword, 10); + await this.users.updatePassword(user, newHashedPassword); } } diff --git a/api/src/modules/users/users.service.ts b/api/src/modules/users/users.service.ts index 999f64ed..1f3ea07b 100644 --- a/api/src/modules/users/users.service.ts +++ b/api/src/modules/users/users.service.ts @@ -26,4 +26,9 @@ export class UsersService { } return this.repo.save(createUserDto); } + + async updatePassword(user: User, newPassword: string) { + user.password = newPassword; + return this.repo.save(user); + } } diff --git a/api/test/e2e/features/password-recovery-reset-password.feature b/api/test/e2e/features/password-recovery-reset-password.feature new file mode 100644 index 00000000..160f5dc4 --- /dev/null +++ b/api/test/e2e/features/password-recovery-reset-password.feature @@ -0,0 +1,12 @@ +Feature: Reset Password + + Scenario: Successfully resetting the password with a valid token + Given a user has a valid reset-password token + When the user attempts to reset their password with a new valid password + Then the user should receive a 201 status code + And the user can log in with the new password + + Scenario: Attempting to reset the password with an expired token + Given a user has an expired reset-password token + When the user attempts to reset their password with a new valid password + Then the user should receive a 401 status code diff --git a/api/test/e2e/steps/password-recovery-reset-email.steps.ts b/api/test/e2e/steps/password-recovery-reset-email.steps.ts new file mode 100644 index 00000000..becd0c44 --- /dev/null +++ b/api/test/e2e/steps/password-recovery-reset-email.steps.ts @@ -0,0 +1,129 @@ +import { defineFeature, loadFeature } from 'jest-cucumber'; +import { Response } from 'supertest'; +import { TestManager } from '../../utils/test-manager'; +import { User } from '@shared/entities/users/user.entity'; +import { JwtService } from '@nestjs/jwt'; +import { ApiConfigService } from '@api/modules/config/app-config.service'; +import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema'; + +const feature = loadFeature( + './test/e2e/features/password-recovery-reset-password.feature', +); + +describe('Reset Password', () => { + defineFeature(feature, (test) => { + let testManager: TestManager; + let jwtService: JwtService; + let resetPasswordSecret: string; + let apiConfigService: ApiConfigService; + + beforeAll(async () => { + testManager = await TestManager.createTestManager(); + jwtService = testManager.moduleFixture.get(JwtService); + apiConfigService = + testManager.moduleFixture.get(ApiConfigService); + }); + + beforeEach(async () => { + resetPasswordSecret = apiConfigService.getJWTConfigByType( + TOKEN_TYPE_ENUM.RESET_PASSWORD, + ).secret; + await testManager.clearDatabase(); + }); + + afterAll(async () => { + await testManager.close(); + }); + + // Scenario 1: Successfully resetting the password with a valid token + test('Successfully resetting the password with a valid token', ({ + given, + when, + then, + and, + }) => { + let user: User; + let resetPasswordToken: string; + let response: Response; + + given('a user has a valid reset-password token', async () => { + user = await testManager.mocks().createUser({ + email: 'validuser@example.com', + password: 'OldPassword123', + }); + resetPasswordToken = jwtService.sign( + { sub: user.id }, + { secret: resetPasswordSecret, expiresIn: '1h' }, + ); + }); + + when( + 'the user attempts to reset their password with a new valid password', + async () => { + response = await testManager + .request() + .post('/authentication/reset-password') + .set('Authorization', `Bearer ${resetPasswordToken}`) + .send({ password: 'NewPassword123' }); + }, + ); + + then( + /the user should receive a (\d+) status code/, + (statusCode: string) => { + expect(response.status).toBe(Number.parseInt(statusCode)); + }, + ); + + and('the user can log in with the new password', async () => { + const loginResponse = await testManager + .request() + .post('/authentication/login') + .send({ email: user.email, password: 'NewPassword123' }); + + expect(loginResponse.status).toBe(201); + expect(loginResponse.body.accessToken).toBeDefined(); + }); + }); + + // Scenario 2: Attempting to reset the password with an expired token + test('Attempting to reset the password with an expired token', ({ + given, + when, + then, + }) => { + let user: User; + let expiredResetPasswordToken: string; + let response: Response; + + given('a user has an expired reset-password token', async () => { + user = await testManager.mocks().createUser({ + email: 'expireduser@example.com', + password: 'OldPassword123', + }); + expiredResetPasswordToken = jwtService.sign( + { sub: user.id }, + { secret: resetPasswordSecret, expiresIn: '1ms' }, + ); + }); + + when( + 'the user attempts to reset their password with a new valid password', + async () => { + response = await testManager + .request() + .post('/authentication/reset-password') + .set('Authorization', `Bearer ${expiredResetPasswordToken}`) + .send({ password: 'NewPassword123' }); + }, + ); + + then( + /the user should receive a (\d+) status code/, + (statusCode: string) => { + expect(response.status).toBe(Number.parseInt(statusCode)); + }, + ); + }); + }); +}); diff --git a/api/test/e2e/steps/password-recovery.steps.ts b/api/test/e2e/steps/password-recovery-send-email.steps.ts similarity index 100% rename from api/test/e2e/steps/password-recovery.steps.ts rename to api/test/e2e/steps/password-recovery-send-email.steps.ts diff --git a/shared/contracts/auth/auth.contract.ts b/shared/contracts/auth/auth.contract.ts index bee9d312..2c477c60 100644 --- a/shared/contracts/auth/auth.contract.ts +++ b/shared/contracts/auth/auth.contract.ts @@ -36,7 +36,7 @@ export const authContract = contract.router({ 201: null, 401: null, }, - body: LogInSchema, + body: z.object({ password: z.string() }), }, requestPasswordRecovery: { method: "POST",