From 719eaca8bc8cfced28a902225ec3f458625d95df Mon Sep 17 00:00:00 2001 From: alexeh Date: Thu, 26 Sep 2024 07:12:18 +0200 Subject: [PATCH 1/4] create endpoint and update contract to activate user --- .../modules/auth/authentication.controller.ts | 15 ++++++++- .../modules/auth/authentication.service.ts | 15 ++++++++- api/src/modules/auth/services/jwt.manager.ts | 4 +-- .../auth/strategies/sign-up.strategy.ts | 33 +++++++++++++++++++ api/src/modules/config/auth-config.handler.ts | 2 +- api/src/modules/events/events.enum.ts | 2 +- api/test/integration/auth/sign-up.spec.ts | 4 +-- shared/contracts/auth.contract.ts | 10 ++++++ shared/schemas/auth/sign-up.schema.ts | 17 ++++++++++ shared/schemas/auth/token-type.schema.ts | 2 +- 10 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 api/src/modules/auth/strategies/sign-up.strategy.ts create mode 100644 shared/schemas/auth/sign-up.schema.ts diff --git a/api/src/modules/auth/authentication.controller.ts b/api/src/modules/auth/authentication.controller.ts index 9e67eaaf..7ede1c78 100644 --- a/api/src/modules/auth/authentication.controller.ts +++ b/api/src/modules/auth/authentication.controller.ts @@ -5,7 +5,6 @@ import { UseInterceptors, ClassSerializerInterceptor, HttpStatus, - UnauthorizedException, } from '@nestjs/common'; import { User } from '@shared/entities/users/user.entity'; import { LocalAuthGuard } from '@api/modules/auth/guards/local-auth.guard'; @@ -18,6 +17,8 @@ import { AuthGuard } from '@nestjs/passport'; import { ResetPassword } from '@api/modules/auth/strategies/reset-password.strategy'; import { authContract } from '@shared/contracts/auth.contract'; import { AuthenticationService } from '@api/modules/auth/authentication.service'; +import { JwtAuthGuard } from '@api/modules/auth/guards/jwt-auth.guard'; +import { SignUp } from '@api/modules/auth/strategies/sign-up.strategy'; @Controller() @UseInterceptors(ClassSerializerInterceptor) @@ -40,6 +41,18 @@ export class AuthenticationController { }); } + @UseGuards(JwtAuthGuard, AuthGuard(SignUp)) + @TsRestHandler(authContract.signUp) + async signUp(@GetUser() user: User): Promise { + return tsRestHandler(authContract.login, async ({ body }) => { + await this.authService.signUp(user, body); + return { + body: null, + status: 201, + }; + }); + } + @UseGuards(AuthGuard(ResetPassword)) @TsRestHandler(authContract.resetPassword) async resetPassword(@GetUser() user: User): Promise { diff --git a/api/src/modules/auth/authentication.service.ts b/api/src/modules/auth/authentication.service.ts index df603b8a..4073dabc 100644 --- a/api/src/modules/auth/authentication.service.ts +++ b/api/src/modules/auth/authentication.service.ts @@ -2,13 +2,15 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { UsersService } from '@api/modules/users/users.service'; import { User } from '@shared/entities/users/user.entity'; import * as bcrypt from 'bcrypt'; -import { CommandBus } from '@nestjs/cqrs'; +import { CommandBus, EventBus } from '@nestjs/cqrs'; import { UserWithAccessToken } from '@shared/dtos/user.dto'; import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema'; import { CreateUserDto } from '@shared/schemas/users/create-user.schema'; import { randomBytes } from 'node:crypto'; import { SendWelcomeEmailCommand } from '@api/modules/notifications/email/commands/send-welcome-email.command'; import { JwtManager } from '@api/modules/auth/services/jwt.manager'; +import { SignUpDto } from '@shared/schemas/auth/sign-up.schema'; +import { UserSignedUpEvent } from '@api/modules/events/user-events/user-signed-up.event'; @Injectable() export class AuthenticationService { @@ -16,6 +18,7 @@ export class AuthenticationService { private readonly usersService: UsersService, private readonly jwtManager: JwtManager, private readonly commandBus: CommandBus, + private readonly eventBus: EventBus, ) {} async validateUser(email: string, password: string): Promise { const user = await this.usersService.findByEmail(email); @@ -47,6 +50,16 @@ export class AuthenticationService { return { user, accessToken }; } + async signUp(user: User, signUpDto: SignUpDto): Promise { + const { password, newPassword } = signUpDto; + if (!(await bcrypt.compare(password, user.password))) { + throw new UnauthorizedException(); + } + user.isActive = true; + await this.usersService.updatePassword(user, newPassword); + this.eventBus.publish(new UserSignedUpEvent(user.id, user.email)); + } + async verifyToken(token: string, type: TOKEN_TYPE_ENUM): Promise { if (await this.jwtManager.isTokenValid(token, type)) { return true; diff --git a/api/src/modules/auth/services/jwt.manager.ts b/api/src/modules/auth/services/jwt.manager.ts index e06da730..a49b7a1e 100644 --- a/api/src/modules/auth/services/jwt.manager.ts +++ b/api/src/modules/auth/services/jwt.manager.ts @@ -58,7 +58,7 @@ export class JwtManager { ): Promise<{ emailConfirmationToken: string; expiresIn: string }> { const { token: emailConfirmationToken, expiresIn } = await this.sign( userId, - TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION, + TOKEN_TYPE_ENUM.SIGN_UP, ); return { emailConfirmationToken, @@ -71,7 +71,7 @@ export class JwtManager { try { const { id } = await this.jwt.verifyAsync(token, { secret }); switch (type) { - case TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION: + case TOKEN_TYPE_ENUM.SIGN_UP: /** * If the user is already active, we don't want to allow them to confirm their email again. */ diff --git a/api/src/modules/auth/strategies/sign-up.strategy.ts b/api/src/modules/auth/strategies/sign-up.strategy.ts new file mode 100644 index 00000000..b3d81331 --- /dev/null +++ b/api/src/modules/auth/strategies/sign-up.strategy.ts @@ -0,0 +1,33 @@ +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { UsersService } from '@api/modules/users/users.service'; +import { ApiConfigService } from '@api/modules/config/app-config.service'; +import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema'; + +export type JwtPayload = { id: string }; + +export const SignUp = 'sign-up'; + +@Injectable() +export class SignUpStrategy extends PassportStrategy(Strategy, SignUp) { + constructor( + private readonly userService: UsersService, + private readonly config: ApiConfigService, + ) { + const { secret } = config.getJWTConfigByType(TOKEN_TYPE_ENUM.SIGN_UP); + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: secret, + }); + } + + async validate(payload: JwtPayload) { + const { id } = payload; + const user = await this.userService.findOneBy(id); + if (!user) { + throw new UnauthorizedException(); + } + return user; + } +} diff --git a/api/src/modules/config/auth-config.handler.ts b/api/src/modules/config/auth-config.handler.ts index 8f8b6865..c48e22ed 100644 --- a/api/src/modules/config/auth-config.handler.ts +++ b/api/src/modules/config/auth-config.handler.ts @@ -25,7 +25,7 @@ export class JwtConfigHandler { ), }; - case TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION: + case TOKEN_TYPE_ENUM.SIGN_UP: return { secret: this.configService.get( 'EMAIL_CONFIRMATION_TOKEN_SECRET', diff --git a/api/src/modules/events/events.enum.ts b/api/src/modules/events/events.enum.ts index 60c25cb0..22b87e9c 100644 --- a/api/src/modules/events/events.enum.ts +++ b/api/src/modules/events/events.enum.ts @@ -1,8 +1,8 @@ export enum API_EVENT_TYPES { USER_SIGNED_UP = 'user.signed_up', + USER_CREATED = 'user.created', USER_PASSWORD_RECOVERY_REQUESTED = 'user.password_recovery_requested', USER_PASSWORD_RECOVERY_REQUESTED_NON_EXISTENT = 'user.password_recovery_requested_non_existent', - EMAIL_FAILED = 'system.email.failed', // More events to come.... } diff --git a/api/test/integration/auth/sign-up.spec.ts b/api/test/integration/auth/sign-up.spec.ts index 0a5a5cec..5f809d9c 100644 --- a/api/test/integration/auth/sign-up.spec.ts +++ b/api/test/integration/auth/sign-up.spec.ts @@ -37,7 +37,7 @@ describe('Create Users', () => { isActive: true, }); const { secret, expiresIn } = apiConfig.getJWTConfigByType( - TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION, + TOKEN_TYPE_ENUM.SIGN_UP, ); const token = jwtService.sign({ id: user.id }, { secret, expiresIn }); @@ -48,7 +48,7 @@ describe('Create Users', () => { .request() .get(authContract.validateToken.path) .set('Authorization', `Bearer ${token}`) - .query({ tokenType: TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION }); + .query({ tokenType: TOKEN_TYPE_ENUM.SIGN_UP }); expect(response.status).toBe(HttpStatus.UNAUTHORIZED); }); diff --git a/shared/contracts/auth.contract.ts b/shared/contracts/auth.contract.ts index 2c477c60..8daca1af 100644 --- a/shared/contracts/auth.contract.ts +++ b/shared/contracts/auth.contract.ts @@ -5,6 +5,7 @@ import { JSONAPIError } from "@shared/dtos/json-api.error"; import { TokenTypeSchema } from "@shared/schemas/auth/token-type.schema"; import { z } from "zod"; import { BearerTokenSchema } from "@shared/schemas/auth/bearer-token.schema"; +import { SignUpSchema } from "@shared/schemas/auth/sign-up.schema"; // TODO: This is a scaffold. We need to define types for responses, zod schemas for body and query param validation etc. @@ -19,6 +20,15 @@ export const authContract = contract.router({ }, body: LogInSchema, }, + signUp: { + method: "POST", + path: "/authentication/sign-up", + responses: { + 201: contract.type(), + 401: contract.type(), + }, + body: SignUpSchema, + }, validateToken: { method: "GET", path: "/authentication/validate-token", diff --git a/shared/schemas/auth/sign-up.schema.ts b/shared/schemas/auth/sign-up.schema.ts new file mode 100644 index 00000000..42ba70da --- /dev/null +++ b/shared/schemas/auth/sign-up.schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { CreateUserSchema } from "@shared/schemas/users/create-user.schema"; + +export const SignUpSchema = z.object({ + password: z + .string({ message: "Password is required" }) + .min(1, "Password is required") + .min(8, "Password must be more than 8 characters") + .max(32, "Password must be less than 32 characters"), + newPassword: z + .string({ message: "Password is required" }) + .min(1, "Password is required") + .min(8, "Password must be more than 8 characters") + .max(32, "Password must be less than 32 characters"), +}); + +export type SignUpDto = z.infer; diff --git a/shared/schemas/auth/token-type.schema.ts b/shared/schemas/auth/token-type.schema.ts index fda0a032..5ce98be0 100644 --- a/shared/schemas/auth/token-type.schema.ts +++ b/shared/schemas/auth/token-type.schema.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export enum TOKEN_TYPE_ENUM { ACCESS = "access", RESET_PASSWORD = "reset-password", - EMAIL_CONFIRMATION = "email-confirmation", + SIGN_UP = "sign-up", } export const TokenTypeSchema = z.object({ From dfafdc07522a3acfb75cf3c11986fa9d74a2b843 Mon Sep 17 00:00:00 2001 From: alexeh Date: Thu, 26 Sep 2024 07:37:10 +0200 Subject: [PATCH 2/4] handle user is active at login in jwt strategy --- api/src/modules/auth/strategies/jwt.strategy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/modules/auth/strategies/jwt.strategy.ts b/api/src/modules/auth/strategies/jwt.strategy.ts index 16a1786c..66f2dbca 100644 --- a/api/src/modules/auth/strategies/jwt.strategy.ts +++ b/api/src/modules/auth/strategies/jwt.strategy.ts @@ -23,7 +23,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { async validate(payload: JwtPayload) { const { id } = payload; const user = await this.userService.findOneBy(id); - if (!user) { + if (!user || !user.isActive) { throw new UnauthorizedException(); } return user; From 768b5e7fb211e885fc25a1f8a4044a9c5041153d Mon Sep 17 00:00:00 2001 From: alexeh Date: Thu, 26 Sep 2024 08:14:25 +0200 Subject: [PATCH 3/4] refactor --- api/src/modules/auth/auth.module.ts | 7 +++- .../modules/auth/authentication.controller.ts | 14 ++++---- .../modules/auth/authentication.service.ts | 4 +++ .../request-password-recovery.command.ts | 3 ++ .../request-password-recovery.handler.ts | 32 +++++++++++++++++++ api/src/modules/auth/services/auth.mailer.ts | 7 ++-- api/src/modules/auth/services/jwt.manager.ts | 8 ++--- api/src/modules/users/users.service.ts | 3 +- .../password-recovery-send-email.feature | 2 +- .../password-recovery-reset-email.steps.ts | 3 ++ api/test/integration/auth/create-user.spec.ts | 9 +++--- api/test/integration/auth/sign-up.spec.ts | 24 +++++++------- api/test/utils/mocks/entity-mocks.ts | 1 + api/test/utils/test-manager.ts | 5 ++- 14 files changed, 90 insertions(+), 32 deletions(-) create mode 100644 api/src/modules/auth/commands/request-password-recovery.command.ts create mode 100644 api/src/modules/auth/commands/request-password-recovery.handler.ts diff --git a/api/src/modules/auth/auth.module.ts b/api/src/modules/auth/auth.module.ts index 95745c4e..5e13f1e2 100644 --- a/api/src/modules/auth/auth.module.ts +++ b/api/src/modules/auth/auth.module.ts @@ -4,11 +4,16 @@ import { AuthMailer } from '@api/modules/auth/services/auth.mailer'; import { NotificationsModule } from '@api/modules/notifications/notifications.module'; import { AuthenticationController } from '@api/modules/auth/authentication.controller'; import { AuthenticationModule } from '@api/modules/auth/authentication.module'; +import { RequestPasswordRecoveryHandler } from '@api/modules/auth/commands/request-password-recovery.handler'; @Module({ imports: [AuthenticationModule, NotificationsModule], controllers: [AuthenticationController], - providers: [PasswordRecoveryService, AuthMailer], + providers: [ + PasswordRecoveryService, + AuthMailer, + RequestPasswordRecoveryHandler, + ], exports: [AuthenticationModule, AuthMailer], }) export class AuthModule {} diff --git a/api/src/modules/auth/authentication.controller.ts b/api/src/modules/auth/authentication.controller.ts index 7ede1c78..edd5d546 100644 --- a/api/src/modules/auth/authentication.controller.ts +++ b/api/src/modules/auth/authentication.controller.ts @@ -19,6 +19,8 @@ import { authContract } from '@shared/contracts/auth.contract'; import { AuthenticationService } from '@api/modules/auth/authentication.service'; import { JwtAuthGuard } from '@api/modules/auth/guards/jwt-auth.guard'; import { SignUp } from '@api/modules/auth/strategies/sign-up.strategy'; +import { CommandBus } from '@nestjs/cqrs'; +import { RequestPasswordRecoveryCommand } from '@api/modules/auth/commands/request-password-recovery.command'; @Controller() @UseInterceptors(ClassSerializerInterceptor) @@ -26,6 +28,7 @@ export class AuthenticationController { constructor( private authService: AuthenticationService, private readonly passwordRecovery: PasswordRecoveryService, + private readonly commandBus: CommandBus, ) {} @Public() @@ -59,12 +62,9 @@ export class AuthenticationController { return tsRestHandler( authContract.resetPassword, async ({ body: { password } }) => { - const userWithAccessToken = await this.passwordRecovery.resetPassword( - user, - password, - ); + await this.authService.updatePassword(user, password); return { - body: userWithAccessToken, + body: null, status: 201, }; }, @@ -78,7 +78,9 @@ export class AuthenticationController { return tsRestHandler( authContract.requestPasswordRecovery, async ({ body: { email } }) => { - await this.passwordRecovery.requestPasswordRecovery(email, origin); + await this.commandBus.execute( + new RequestPasswordRecoveryCommand(email), + ); return { body: null, status: HttpStatus.CREATED, diff --git a/api/src/modules/auth/authentication.service.ts b/api/src/modules/auth/authentication.service.ts index 4073dabc..b10d84a4 100644 --- a/api/src/modules/auth/authentication.service.ts +++ b/api/src/modules/auth/authentication.service.ts @@ -66,4 +66,8 @@ export class AuthenticationService { } throw new UnauthorizedException(); } + + async updatePassword(user: User, newPassword: string): Promise { + await this.usersService.updatePassword(user, newPassword); + } } diff --git a/api/src/modules/auth/commands/request-password-recovery.command.ts b/api/src/modules/auth/commands/request-password-recovery.command.ts new file mode 100644 index 00000000..32ad4cab --- /dev/null +++ b/api/src/modules/auth/commands/request-password-recovery.command.ts @@ -0,0 +1,3 @@ +export class RequestPasswordRecoveryCommand { + constructor(public readonly email: string) {} +} diff --git a/api/src/modules/auth/commands/request-password-recovery.handler.ts b/api/src/modules/auth/commands/request-password-recovery.handler.ts new file mode 100644 index 00000000..46776d84 --- /dev/null +++ b/api/src/modules/auth/commands/request-password-recovery.handler.ts @@ -0,0 +1,32 @@ +import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs'; +import { AuthMailer } from '@api/modules/auth/services/auth.mailer'; +import { RequestPasswordRecoveryCommand } from '@api/modules/auth/commands/request-password-recovery.command'; +import { UsersService } from '@api/modules/users/users.service'; +import { PasswordRecoveryRequestedEvent } from '@api/modules/events/user-events/password-recovery-requested.event'; +import { NotFoundException } from '@nestjs/common'; + +@CommandHandler(RequestPasswordRecoveryCommand) +export class RequestPasswordRecoveryHandler + implements ICommandHandler +{ + constructor( + private readonly users: UsersService, + private readonly authMailer: AuthMailer, + private readonly eventBus: EventBus, + ) {} + + async execute(command: RequestPasswordRecoveryCommand): Promise { + const { email } = command; + const user = await this.users.findByEmail(email); + if (!user) { + this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, null)); + throw new NotFoundException(`Email ${email} not found`); + } + await this.authMailer.sendPasswordRecoveryEmail({ + user, + // TODO: Origin must come from env vars + origin: 'http://localhost:3000', + }); + this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, user.id)); + } +} diff --git a/api/src/modules/auth/services/auth.mailer.ts b/api/src/modules/auth/services/auth.mailer.ts index a646f98c..43a413fa 100644 --- a/api/src/modules/auth/services/auth.mailer.ts +++ b/api/src/modules/auth/services/auth.mailer.ts @@ -51,12 +51,13 @@ export class AuthMailer { user: User; defaultPassword: string; }) { - const { emailConfirmationToken, expiresIn } = - await this.jwt.signEmailConfirmationToken(welcomeEmailDto.user.id); + const { signUpToken, expiresIn } = await this.jwt.signSignUpToken( + welcomeEmailDto.user.id, + ); // TODO: We need to know the URL to confirm the email, we could rely on origin but we would need to pass it through a lot of code. // probably better to have a config value for this. - const resetPasswordUrl = `TODO/auth/sign-up/${emailConfirmationToken}`; + const resetPasswordUrl = `TODO/auth/sign-up/${signUpToken}`; const htmlContent: string = `

Dear User,

diff --git a/api/src/modules/auth/services/jwt.manager.ts b/api/src/modules/auth/services/jwt.manager.ts index a49b7a1e..6f556a6c 100644 --- a/api/src/modules/auth/services/jwt.manager.ts +++ b/api/src/modules/auth/services/jwt.manager.ts @@ -53,15 +53,15 @@ export class JwtManager { }; } - async signEmailConfirmationToken( + async signSignUpToken( userId: string, - ): Promise<{ emailConfirmationToken: string; expiresIn: string }> { - const { token: emailConfirmationToken, expiresIn } = await this.sign( + ): Promise<{ signUpToken: string; expiresIn: string }> { + const { token: signUpToken, expiresIn } = await this.sign( userId, TOKEN_TYPE_ENUM.SIGN_UP, ); return { - emailConfirmationToken, + signUpToken: signUpToken, expiresIn, }; } diff --git a/api/src/modules/users/users.service.ts b/api/src/modules/users/users.service.ts index f8cf45b0..d064149a 100644 --- a/api/src/modules/users/users.service.ts +++ b/api/src/modules/users/users.service.ts @@ -2,6 +2,7 @@ import { ConflictException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from '@shared/entities/users/user.entity'; import { Repository } from 'typeorm'; +import * as bcrypt from 'bcrypt'; @Injectable() export class UsersService { @@ -26,7 +27,7 @@ export class UsersService { } async updatePassword(user: User, newPassword: string) { - user.password = newPassword; + user.password = await bcrypt.hash(newPassword, 10); return this.repo.save(user); } diff --git a/api/test/e2e/features/password-recovery-send-email.feature b/api/test/e2e/features/password-recovery-send-email.feature index 00a7f99c..1ec3d7b9 100644 --- a/api/test/e2e/features/password-recovery-send-email.feature +++ b/api/test/e2e/features/password-recovery-send-email.feature @@ -8,5 +8,5 @@ Feature: Password Recovery Scenario: No email should be sent if the user is not found When the user requests password recovery with an invalid email - Then the user should receive a 201 status code + Then the user should receive a 404 status code And no email should be sent diff --git a/api/test/e2e/steps/password-recovery-reset-email.steps.ts b/api/test/e2e/steps/password-recovery-reset-email.steps.ts index becd0c44..f1254b42 100644 --- a/api/test/e2e/steps/password-recovery-reset-email.steps.ts +++ b/api/test/e2e/steps/password-recovery-reset-email.steps.ts @@ -28,6 +28,9 @@ describe('Reset Password', () => { resetPasswordSecret = apiConfigService.getJWTConfigByType( TOKEN_TYPE_ENUM.RESET_PASSWORD, ).secret; + }); + + afterEach(async () => { await testManager.clearDatabase(); }); diff --git a/api/test/integration/auth/create-user.spec.ts b/api/test/integration/auth/create-user.spec.ts index 2443d652..79e6a029 100644 --- a/api/test/integration/auth/create-user.spec.ts +++ b/api/test/integration/auth/create-user.spec.ts @@ -37,9 +37,10 @@ describe('Create Users', () => { // Given a user exists with valid credentials // But the user has the role partner - const user = await testManager - .mocks() - .createUser({ role: ROLES.PARTNER, email: 'random@test.com' }); + const user = await testManager.mocks().createUser({ + role: ROLES.PARTNER, + email: 'random@test.com', + }); const { jwtToken } = await testManager.logUserIn(user); // When the user creates a new user @@ -72,7 +73,7 @@ describe('Create Users', () => { ); }); - test('An Admin registers a new user', async () => { + test('An Admin registers a new user ', async () => { // Given a admin user exists with valid credentials // beforeAll const newUser = { diff --git a/api/test/integration/auth/sign-up.spec.ts b/api/test/integration/auth/sign-up.spec.ts index 5f809d9c..bf46c5c5 100644 --- a/api/test/integration/auth/sign-up.spec.ts +++ b/api/test/integration/auth/sign-up.spec.ts @@ -1,22 +1,20 @@ import { TestManager } from '../../utils/test-manager'; import { HttpStatus } from '@nestjs/common'; import { ApiConfigService } from '@api/modules/config/app-config.service'; -import { JwtService } from '@nestjs/jwt'; import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema'; import { authContract } from '@shared/contracts/auth.contract'; import { ROLES } from '@api/modules/auth/roles.enum'; +import { JwtManager } from '@api/modules/auth/services/jwt.manager'; //create-user.feature describe('Create Users', () => { let testManager: TestManager; - let apiConfig: ApiConfigService; - let jwtService: JwtService; + let jwtManager: JwtManager; beforeAll(async () => { testManager = await TestManager.createTestManager(); - apiConfig = testManager.getModule(ApiConfigService); - jwtService = testManager.getModule(JwtService); + jwtManager = testManager.getModule(JwtManager); }); afterEach(async () => { @@ -30,17 +28,12 @@ describe('Create Users', () => { test('A sign-up token should not be valid if the user bound to that token has already been activated', async () => { // Given a user exists with valid credentials // But the user has the role partner - const user = await testManager.mocks().createUser({ role: ROLES.PARTNER, email: 'random@test.com', - isActive: true, }); - const { secret, expiresIn } = apiConfig.getJWTConfigByType( - TOKEN_TYPE_ENUM.SIGN_UP, - ); - const token = jwtService.sign({ id: user.id }, { secret, expiresIn }); + const token = jwtManager.signSignUpToken(user.id); // When the user creates a new user @@ -52,4 +45,13 @@ describe('Create Users', () => { expect(response.status).toBe(HttpStatus.UNAUTHORIZED); }); + + test('Sign up should fail if the current password is incorrect', async () => { + const user = await testManager.mocks().createUser({ + role: ROLES.PARTNER, + email: 'random@test.com', + isActive: true, + }); + const token = await jwtManager.signSignUpToken(user.id); + }); }); diff --git a/api/test/utils/mocks/entity-mocks.ts b/api/test/utils/mocks/entity-mocks.ts index 7748b856..ca050b05 100644 --- a/api/test/utils/mocks/entity-mocks.ts +++ b/api/test/utils/mocks/entity-mocks.ts @@ -12,6 +12,7 @@ export const createUser = async ( email: 'test@user.com', ...additionalData, password: await hash(usedPassword, salt), + isActive: true, }; await dataSource.getRepository(User).save(user); diff --git a/api/test/utils/test-manager.ts b/api/test/utils/test-manager.ts index aa7d49f3..ced8e63f 100644 --- a/api/test/utils/test-manager.ts +++ b/api/test/utils/test-manager.ts @@ -73,7 +73,10 @@ export class TestManager { } async setUpTestUser() { - const user = await createUser(this.getDataSource(), { role: ROLES.ADMIN }); + const user = await createUser(this.getDataSource(), { + role: ROLES.ADMIN, + isActive: true, + }); return logUserIn(this, user); } From 142b20c049e7ff3b889d01f68b159d459b4b33df Mon Sep 17 00:00:00 2001 From: alexeh Date: Thu, 26 Sep 2024 09:34:38 +0200 Subject: [PATCH 4/4] test signup flow --- api/src/modules/auth/auth.module.ts | 7 +-- .../modules/auth/authentication.controller.ts | 7 +-- api/src/modules/auth/authentication.module.ts | 8 +++ .../services/password-recovery.service.ts | 38 -------------- api/test/integration/auth/sign-up.spec.ts | 51 ++++++++++++++++--- 5 files changed, 56 insertions(+), 55 deletions(-) delete mode 100644 api/src/modules/auth/services/password-recovery.service.ts diff --git a/api/src/modules/auth/auth.module.ts b/api/src/modules/auth/auth.module.ts index 5e13f1e2..840e7eff 100644 --- a/api/src/modules/auth/auth.module.ts +++ b/api/src/modules/auth/auth.module.ts @@ -1,5 +1,4 @@ import { Module } from '@nestjs/common'; -import { PasswordRecoveryService } from '@api/modules/auth/services/password-recovery.service'; import { AuthMailer } from '@api/modules/auth/services/auth.mailer'; import { NotificationsModule } from '@api/modules/notifications/notifications.module'; import { AuthenticationController } from '@api/modules/auth/authentication.controller'; @@ -9,11 +8,7 @@ import { RequestPasswordRecoveryHandler } from '@api/modules/auth/commands/reque @Module({ imports: [AuthenticationModule, NotificationsModule], controllers: [AuthenticationController], - providers: [ - PasswordRecoveryService, - AuthMailer, - RequestPasswordRecoveryHandler, - ], + providers: [AuthMailer, RequestPasswordRecoveryHandler], exports: [AuthenticationModule, AuthMailer], }) export class AuthModule {} diff --git a/api/src/modules/auth/authentication.controller.ts b/api/src/modules/auth/authentication.controller.ts index edd5d546..1b48b38f 100644 --- a/api/src/modules/auth/authentication.controller.ts +++ b/api/src/modules/auth/authentication.controller.ts @@ -10,14 +10,12 @@ import { User } from '@shared/entities/users/user.entity'; import { LocalAuthGuard } from '@api/modules/auth/guards/local-auth.guard'; import { GetUser } from '@api/modules/auth/decorators/get-user.decorator'; import { Public } from '@api/modules/auth/decorators/is-public.decorator'; -import { PasswordRecoveryService } from '@api/modules/auth/services/password-recovery.service'; import { tsRestHandler, TsRestHandler } from '@ts-rest/nest'; import { ControllerResponse } from '@api/types/controller-response.type'; import { AuthGuard } from '@nestjs/passport'; import { ResetPassword } from '@api/modules/auth/strategies/reset-password.strategy'; import { authContract } from '@shared/contracts/auth.contract'; import { AuthenticationService } from '@api/modules/auth/authentication.service'; -import { JwtAuthGuard } from '@api/modules/auth/guards/jwt-auth.guard'; import { SignUp } from '@api/modules/auth/strategies/sign-up.strategy'; import { CommandBus } from '@nestjs/cqrs'; import { RequestPasswordRecoveryCommand } from '@api/modules/auth/commands/request-password-recovery.command'; @@ -27,7 +25,6 @@ import { RequestPasswordRecoveryCommand } from '@api/modules/auth/commands/reque export class AuthenticationController { constructor( private authService: AuthenticationService, - private readonly passwordRecovery: PasswordRecoveryService, private readonly commandBus: CommandBus, ) {} @@ -44,10 +41,10 @@ export class AuthenticationController { }); } - @UseGuards(JwtAuthGuard, AuthGuard(SignUp)) + @UseGuards(AuthGuard(SignUp)) @TsRestHandler(authContract.signUp) async signUp(@GetUser() user: User): Promise { - return tsRestHandler(authContract.login, async ({ body }) => { + return tsRestHandler(authContract.signUp, async ({ body }) => { await this.authService.signUp(user, body); return { body: null, diff --git a/api/src/modules/auth/authentication.module.ts b/api/src/modules/auth/authentication.module.ts index 83e4acea..a1621b97 100644 --- a/api/src/modules/auth/authentication.module.ts +++ b/api/src/modules/auth/authentication.module.ts @@ -11,6 +11,7 @@ import { JwtStrategy } from '@api/modules/auth/strategies/jwt.strategy'; import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema'; import { ResetPasswordJwtStrategy } from '@api/modules/auth/strategies/reset-password.strategy'; import { JwtManager } from '@api/modules/auth/services/jwt.manager'; +import { SignUpStrategy } from '@api/modules/auth/strategies/sign-up.strategy'; @Module({ imports: [ @@ -46,6 +47,13 @@ import { JwtManager } from '@api/modules/auth/services/jwt.manager'; }, inject: [UsersService, ApiConfigService], }, + { + provide: SignUpStrategy, + useFactory: (users: UsersService, config: ApiConfigService) => { + return new SignUpStrategy(users, config); + }, + inject: [UsersService, ApiConfigService], + }, ], exports: [UsersModule, AuthenticationService, JwtManager], }) diff --git a/api/src/modules/auth/services/password-recovery.service.ts b/api/src/modules/auth/services/password-recovery.service.ts deleted file mode 100644 index f3df1478..00000000 --- a/api/src/modules/auth/services/password-recovery.service.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { UsersService } from '@api/modules/users/users.service'; -import { AuthMailer } from '@api/modules/auth/services/auth.mailer'; -import { EventBus } from '@nestjs/cqrs'; -import { PasswordRecoveryRequestedEvent } from '@api/modules/events/user-events/password-recovery-requested.event'; -import { User } from '@shared/entities/users/user.entity'; -import * as bcrypt from 'bcrypt'; - -@Injectable() -export class PasswordRecoveryService { - logger: Logger = new Logger(PasswordRecoveryService.name); - constructor( - private readonly users: UsersService, - private readonly authMailer: AuthMailer, - private readonly eventBus: EventBus, - ) {} - - async requestPasswordRecovery(email: string, origin: string): Promise { - const user = await this.users.findByEmail(email); - if (!user) { - this.logger.warn( - `Email ${email} not found when trying to recover password`, - ); - this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, null)); - return; - } - await this.authMailer.sendPasswordRecoveryEmail({ - user, - origin, - }); - this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, user.id)); - } - - async resetPassword(user: User, newPassword: string): Promise { - const newHashedPassword = await bcrypt.hash(newPassword, 10); - await this.users.updatePassword(user, newHashedPassword); - } -} diff --git a/api/test/integration/auth/sign-up.spec.ts b/api/test/integration/auth/sign-up.spec.ts index bf46c5c5..9f28e3b3 100644 --- a/api/test/integration/auth/sign-up.spec.ts +++ b/api/test/integration/auth/sign-up.spec.ts @@ -1,10 +1,10 @@ import { TestManager } from '../../utils/test-manager'; import { HttpStatus } from '@nestjs/common'; -import { ApiConfigService } from '@api/modules/config/app-config.service'; import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema'; import { authContract } from '@shared/contracts/auth.contract'; import { ROLES } from '@api/modules/auth/roles.enum'; import { JwtManager } from '@api/modules/auth/services/jwt.manager'; +import { User } from '@shared/entities/users/user.entity'; //create-user.feature @@ -33,25 +33,64 @@ describe('Create Users', () => { email: 'random@test.com', }); - const token = jwtManager.signSignUpToken(user.id); + const { signUpToken } = await jwtManager.signSignUpToken(user.id); // When the user creates a new user const response = await testManager .request() .get(authContract.validateToken.path) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${signUpToken}`) .query({ tokenType: TOKEN_TYPE_ENUM.SIGN_UP }); expect(response.status).toBe(HttpStatus.UNAUTHORIZED); }); - test('Sign up should fail if the current password is incorrect', async () => { + test('Sign up should fail if the auto-generated password is incorrect', async () => { const user = await testManager.mocks().createUser({ role: ROLES.PARTNER, email: 'random@test.com', - isActive: true, + isActive: false, }); - const token = await jwtManager.signSignUpToken(user.id); + const { signUpToken } = await jwtManager.signSignUpToken(user.id); + + const response = await testManager + .request() + .post(authContract.signUp.path) + .set('Authorization', `Bearer ${signUpToken}`) + .query({ tokenType: TOKEN_TYPE_ENUM.SIGN_UP }) + .send({ password: 'wrongpassword', newPassword: 'newpassword' }); + + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + + test('Sign up should succeed if the auto-generated password is correct and the user should be activated and allowed to get a access token', async () => { + const user = await testManager.mocks().createUser({ + role: ROLES.PARTNER, + email: 'test@test.com', + isActive: false, + }); + const { signUpToken } = await jwtManager.signSignUpToken(user.id); + + const response = await testManager + .request() + .post(authContract.signUp.path) + .set('Authorization', `Bearer ${signUpToken}`) + .send({ password: user.password, newPassword: 'newpassword' }); + + expect(response.status).toBe(HttpStatus.CREATED); + const foundUser = await testManager + .getDataSource() + .getRepository(User) + .findOneBy({ id: user.id }); + + expect(foundUser.isActive).toBe(true); + + const login = await testManager + .request() + .post(authContract.login.path) + .send({ email: user.email, password: 'newpassword' }); + + expect(login.body.accessToken).toBeDefined(); }); });