diff --git a/api/src/modules/auth/authentication.controller.ts b/api/src/modules/auth/authentication.controller.ts index e21c258d..5fef5b8f 100644 --- a/api/src/modules/auth/authentication.controller.ts +++ b/api/src/modules/auth/authentication.controller.ts @@ -19,6 +19,7 @@ import { AuthenticationService } from '@api/modules/auth/authentication.service' 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'; +import { EmailConfirmation } from '@api/modules/auth/strategies/email-update.strategy'; @Controller() @UseInterceptors(ClassSerializerInterceptor) @@ -86,6 +87,21 @@ export class AuthenticationController { ); } + @UseGuards(AuthGuard(EmailConfirmation)) + @TsRestHandler(authContract.confirmEmail) + async confirmEmail(@GetUser() user: User): Promise { + return tsRestHandler( + authContract.confirmEmail, + async ({ query: { newEmail } }) => { + await this.authService.confirmEmail(user, newEmail); + return { + body: null, + status: HttpStatus.OK, + }; + }, + ); + } + @Public() @TsRestHandler(authContract.validateToken) async validateToken(): Promise { diff --git a/api/src/modules/auth/authentication.module.ts b/api/src/modules/auth/authentication.module.ts index 5741c276..2e52ff6b 100644 --- a/api/src/modules/auth/authentication.module.ts +++ b/api/src/modules/auth/authentication.module.ts @@ -13,6 +13,7 @@ import { ResetPasswordJwtStrategy } from '@api/modules/auth/strategies/reset-pas import { JwtManager } from '@api/modules/auth/services/jwt.manager'; import { SignUpStrategy } from '@api/modules/auth/strategies/sign-up.strategy'; import { PasswordManager } from '@api/modules/auth/services/password.manager'; +import { EmailConfirmationJwtStrategy } from '@api/modules/auth/strategies/email-update.strategy'; @Module({ imports: [ @@ -56,6 +57,13 @@ import { PasswordManager } from '@api/modules/auth/services/password.manager'; }, inject: [UsersService, ApiConfigService], }, + { + provide: EmailConfirmationJwtStrategy, + useFactory: (users: UsersService, config: ApiConfigService) => { + return new EmailConfirmationJwtStrategy(users, config); + }, + inject: [UsersService, ApiConfigService], + }, ], exports: [UsersModule, AuthenticationService, JwtManager], }) diff --git a/api/src/modules/auth/authentication.service.ts b/api/src/modules/auth/authentication.service.ts index 07c6237d..6b41e871 100644 --- a/api/src/modules/auth/authentication.service.ts +++ b/api/src/modules/auth/authentication.service.ts @@ -139,4 +139,13 @@ export class AuthenticationService { new SendEmailConfirmationEmailCommand(user, newEmail, origin), ); } + + async confirmEmail(user: User, newEmail: string): Promise { + const existingUser = await this.usersService.findByEmail(newEmail); + if (existingUser) { + throw new ConflictException(`Email already in use`); + } + user.email = newEmail; + await this.usersService.saveUser(user); + } } diff --git a/api/src/modules/auth/strategies/email-update.strategy.ts b/api/src/modules/auth/strategies/email-update.strategy.ts new file mode 100644 index 00000000..57cec46d --- /dev/null +++ b/api/src/modules/auth/strategies/email-update.strategy.ts @@ -0,0 +1,39 @@ +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'; +import { Request } from 'express'; + +export type JwtPayload = { id: string }; + +export const EmailConfirmation = 'email-confirmation'; + +@Injectable() +export class EmailConfirmationJwtStrategy extends PassportStrategy( + Strategy, + EmailConfirmation, +) { + constructor( + private readonly userService: UsersService, + private readonly config: ApiConfigService, + ) { + const { secret } = config.getJWTConfigByType( + TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION, + ); + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: secret, + }); + } + + async validate(req: Request, payload: JwtPayload) { + const { id } = payload; + const user = await this.userService.findOneBy(id); + if (!user) { + throw new UnauthorizedException(); + } + return user; + } +} diff --git a/api/test/integration/users/users-email-update.spec.ts b/api/test/integration/users/users-email-update.spec.ts index 6e7c2b86..8ba55204 100644 --- a/api/test/integration/users/users-email-update.spec.ts +++ b/api/test/integration/users/users-email-update.spec.ts @@ -5,14 +5,19 @@ import { usersContract } from '@shared/contracts/users.contract'; import { ROLES } from '@shared/entities/users/roles.enum'; import { MockEmailService } from '../../utils/mocks/mock-email.service'; import { IEmailServiceToken } from '@api/modules/notifications/email/email-service.interface'; +import { JwtManager } from '@api/modules/auth/services/jwt.manager'; +import { User } from '@shared/entities/users/user.entity'; +import { authContract } from '@shared/contracts/auth.contract'; describe('Users ME (e2e)', () => { let testManager: TestManager; + let jwt: JwtManager; let emailService: MockEmailService; beforeAll(async () => { testManager = await TestManager.createTestManager(); emailService = testManager.getModule(IEmailServiceToken); + jwt = testManager.getModule(JwtManager); }); beforeEach(async () => { @@ -87,56 +92,37 @@ describe('Users ME (e2e)', () => { .mocks() .createUser({ email: 'test@test.com', role: ROLES.PARTNER }); - const { jwtToken, password: oldPassword } = - await testManager.logUserIn(user); - const newPassword = 'newPassword'; + const { emailUpdateToken } = await jwt.signEmailUpdateToken(user.id); + const newEmail = 'new-mail@mail.com'; const response = await testManager .request() - .patch(usersContract.updatePassword.path) - .send({ password: oldPassword, newPassword }) - .set('Authorization', `Bearer ${jwtToken}`); + .get(authContract.confirmEmail.path) + .query({ newEmail }) + .set('Authorization', `Bearer ${emailUpdateToken}`); expect(response.status).toBe(200); - expect(response.body.data.id).toEqual(user.id); - - const { jwtToken: noToken } = await testManager.logUserIn({ - ...user, - password: oldPassword, - }); - expect(noToken).toBeUndefined(); - - const { jwtToken: newToken } = await testManager.logUserIn({ - ...user, - password: newPassword, - }); - - expect(newToken).toBeDefined(); + const userWithUpdatedEmail = await testManager + .getDataSource() + .getRepository(User) + .findOneBy({ email: newEmail }); + expect(userWithUpdatedEmail.id).toEqual(user.id); }); - it('should fail if the email confirmation token is not authorized', async () => { + it('should fail if the new email is already in use', async () => { const user = await createUser(testManager.getDataSource(), { email: 'user@test.com', role: ROLES.PARTNER, }); - const { jwtToken } = await testManager.logUserIn(user); - const newName = 'newName'; - const response = await testManager - .request() - .patch(usersContract.updateMe.path) - .send({ name: newName }) - .set('Authorization', `Bearer ${jwtToken}`); - expect(response.status).toBe(201); - expect(response.body.data.id).toEqual(user.id); - expect(response.body.data.name).toEqual(newName); + const { emailUpdateToken } = await jwt.signEmailUpdateToken(user.id); - // Previous token should work after updating the user's email - const userMeResponse = await testManager + const response = await testManager .request() - .get('/users/me') - .set('Authorization', `Bearer ${jwtToken}`); + .get(authContract.confirmEmail.path) + .query({ newEmail: user.email }) + .set('Authorization', `Bearer ${emailUpdateToken}`); - expect(userMeResponse.status).toBe(200); - expect(userMeResponse.body.data.name).toEqual(newName); + expect(response.status).toBe(409); + expect(response.body.errors[0].title).toBe('Email already in use'); }); }); }); diff --git a/shared/contracts/auth.contract.ts b/shared/contracts/auth.contract.ts index 6f5b622e..3def4b43 100644 --- a/shared/contracts/auth.contract.ts +++ b/shared/contracts/auth.contract.ts @@ -57,7 +57,7 @@ export const authContract = contract.router({ query: z.object({ newEmail: z.string().email() }), path: "/authentication/confirm-email", responses: { - 201: null, + 200: null, }, }, });