diff --git a/src/users/change-email.dto.ts b/src/users/change-email.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb783e4490f15c9c32b2f78d4f6dae4f679553fd --- /dev/null +++ b/src/users/change-email.dto.ts @@ -0,0 +1,10 @@ +import { IsEmail, IsNotEmpty } from 'class-validator'; + +export class EmailChangeDto { + @IsNotEmpty() + @IsEmail() + readonly newEmail: string; + @IsNotEmpty() + @IsEmail() + readonly oldEmail: string; +} diff --git a/src/users/user.interface.ts b/src/users/user.interface.ts index 84dd109cee9d506ff30248a93ade10eebe9886a2..93aec2489ff596ca7894df6cd4dc8ca2de07fbbd 100644 --- a/src/users/user.interface.ts +++ b/src/users/user.interface.ts @@ -8,4 +8,6 @@ export interface IUser extends Document { validationToken: string; resetPasswordToken: string; role: number; + changeEmailToken: string; + newEmail: string; } diff --git a/src/users/user.schema.ts b/src/users/user.schema.ts index a37539e4df4cb2c1091f183cb125cb36ac2b976e..b429332fe8b17d06eb53488ccf6285ffb7224d19 100644 --- a/src/users/user.schema.ts +++ b/src/users/user.schema.ts @@ -19,6 +19,12 @@ export class User { @Prop({ enum: [UserRole.admin, UserRole.user], default: UserRole.user }) role: number; + + @Prop({ default: null }) + changeEmailToken: string; + + @Prop({ default: null }) + newEmail: string; } export const UserSchema = SchemaFactory.createForClass(User); diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index c870c5551a64a72b02f0bbc1ace87ec28e0666e1..e37378c591129af0f1e7ba5577eee6ac31d1b235 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Query, Request, UseGuards } from '@ import { ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { PasswordChangeDto } from './change-password.dto'; +import { EmailChangeDto } from './change-email.dto'; import { CreateUserDto } from './create-user.dto'; import { PasswordResetApplyDto } from './reset-password-apply.dto'; import { PasswordResetDto } from './reset-password.dto'; @@ -47,6 +48,22 @@ export class UsersController { ); } + @UseGuards(JwtAuthGuard) + @Post('change-email') + @ApiResponse({ status: 201, description: 'Email confirmation send' }) + @ApiResponse({ status: 401, description: 'Invalid Email' }) + public async changeEmail(@Request() req, @Body() emailChangeDto: EmailChangeDto) { + return this.usersService.changeUserEmail(emailChangeDto); + } + + @UseGuards(JwtAuthGuard) + @Post('verify-change-email') + @ApiResponse({ status: 201, description: 'Email changed' }) + @ApiResponse({ status: 401, description: 'Invalid Token' }) + public async verifyAndUpdateEmail(@Request() req, @Query('token') token: string) { + return this.usersService.verifyAndUpdateUserEmail(token); + } + @Post('reset-password') @ApiResponse({ status: 200, description: 'Email sent if account exist' }) public async resetPassword(@Body() passwordReset: PasswordResetDto) { diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index 3ce659dfd93d0d596c0ed0dba9a948f20233056f..c8e1e0c15d4ed0239e868b76151b887da19ceaf9 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -6,6 +6,7 @@ import { getModelToken } from '@nestjs/mongoose'; import { CreateUserDto } from './create-user.dto'; import { HttpException, HttpStatus } from '@nestjs/common'; import { LoginDto } from '../auth/login-dto'; +import { EmailChangeDto } from './change-email.dto'; describe('UsersService', () => { let service: UsersService; @@ -38,6 +39,8 @@ describe('UsersService', () => { emailVerified: false, email: 'jacques.dupont@mii.com', password: '$2a$12$vLQjJ9zAWyUwiXLeQDa6w.yazDArYIpf2WnQF1jRHOjBxADEjUEA3', + newEmail: '', + changeEmailToken: '', resetPasswordToken: null, }; const userDto: CreateUserDto = { email: 'jacques.dupont@mii.com', password: 'test1A!!' }; //NOSONAR @@ -72,6 +75,8 @@ describe('UsersService', () => { email: 'jacques.dupont@mii.com', password: '$2a$12$vLQjJ9zAWyUwiXLeQDa6w.yazDArYIpf2WnQF1jRHOjBxADEjUEA3', role: 0, + newEmail: '', + changeEmailToken: '', resetPasswordToken: null, }; const loginDto: LoginDto = { email: 'jacques.dupont@mii.com', password: 'test1A!!' }; //NOSONAR @@ -120,6 +125,59 @@ describe('UsersService', () => { }); }); + describe('changeUserEmail', () => { + it('should find and add token', async () => { + const result = { + validationToken: '', + emailVerified: true, + email: 'jacques.dupont@mii.com', + password: '$2a$12$vLQjJ9zAWyUwiXLeQDa6w.yazDArYIpf2WnQF1jRHOjBxADEjUEA3', + role: 0, + newEmail: 'test.dupont@mail.com', + resetPasswordToken: '', + changeEmailToken: + '9bb3542bdc5ca8801ad4cee00403c1052bc95dee768dcbb65b1f719870578ed79f71f52fdc3e6bf02fd200a72b8b6f56fc26950df30c8cd7e427a485f80181b9', + }; + const emailDto: EmailChangeDto = { newEmail: 'test.dupont@mail.com', oldEmail: 'jacques.dupont@mii.com' }; //NOSONAR + jest.spyOn(service, 'changeUserEmail').mockImplementation(async (): Promise<User> => result); + expect(await service.changeUserEmail(emailDto)).toBe(result); + }); + it('user does not exist, should be unauthorized issue', async () => { + const result: HttpException = new HttpException('Email sent if account exist', HttpStatus.UNAUTHORIZED); + const emailDto: EmailChangeDto = { newEmail: 'test.dupont@mail.com', oldEmail: 'jacques.dupont@mii.com' }; //NOSONAR + jest.spyOn(service, 'changeUserEmail').mockImplementation(async (): Promise<any> => result); + expect(await service.changeUserEmail(emailDto)).toBe(result); + }); + it('email already used, should be not acceptable issue', async () => { + const result: HttpException = new HttpException('Email already used', HttpStatus.NOT_ACCEPTABLE); + const emailDto: EmailChangeDto = { newEmail: 'jacques.dupont@mii.com', oldEmail: 'jacques.dupont@mii.com' }; //NOSONAR + jest.spyOn(service, 'changeUserEmail').mockImplementation(async (): Promise<any> => result); + expect(await service.changeUserEmail(emailDto)).toBe(result); + }); + it('should change email', async () => { + const result = { + validationToken: '', + emailVerified: true, + email: 'test.dupont@mail.com', + password: '$2a$12$vLQjJ9zAWyUwiXLeQDa6w.yazDArYIpf2WnQF1jRHOjBxADEjUEA3', + role: 0, + newEmail: '', + resetPasswordToken: '', + changeEmailToken: '', + }; + const token = + '9bb3542bdc5ca8801ad4cee00403c1052bc95dee768dcbb65b1f719870578ed79f71f52fdc3e6bf02fd200a72b8b6f56fc26950df30c8cd7e427a485f80181b9'; //NOSONAR + jest.spyOn(service, 'verifyAndUpdateUserEmail').mockImplementation(async (): Promise<User> => result); + expect(await service.verifyAndUpdateUserEmail(token)).toBe(result); + }); + it('should not change email', async () => { + const result: HttpException = new HttpException('Invalid token', HttpStatus.UNAUTHORIZED); + const token = '9bb3542bdc5ca8801aa72b8b6f56fc26950df30c8cd7e427a485f80181b9FAKETOKEN'; //NOSONAR + jest.spyOn(service, 'verifyAndUpdateUserEmail').mockImplementation(async (): Promise<any> => result); + expect(await service.verifyAndUpdateUserEmail(token)).toBe(result); + }); + }); + describe('sendResetPasswordEmail', () => { it('should not send email', async () => { const result = new HttpException('Email sent if account exist', HttpStatus.OK); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index d38588f6ffd278a373f5cab356c6a7b2d4551cd2..d05f8d5dea0d016dae3a2648c8838826dbb89d46 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -9,6 +9,7 @@ import { CreateUserDto } from './create-user.dto'; import { User } from './user.schema'; import { MailerService } from '../mailer/mailer.service'; import { IUser } from './user.interface'; +import { EmailChangeDto } from './change-email.dto'; @Injectable() export class UsersService { @@ -134,6 +135,43 @@ export class UsersService { } } + public async changeUserEmail(emailDto: EmailChangeDto): Promise<any> { + const user = await this.findOne(emailDto.oldEmail); + const alreadyUsed = await this.findOne(emailDto.newEmail); + if (user) { + if (!alreadyUsed) { + const config = this.mailerService.config; + const ejsPath = this.mailerService.getTemplateLocation(config.templates.changeEmail.ejs); + const jsonConfig = this.mailerService.loadJsonConfig(config.templates.changeEmail.json); + const token = crypto.randomBytes(64).toString('hex'); + const html = await ejs.renderFile(ejsPath, { + config, + token: token, + }); + this.mailerService.send(user.email, jsonConfig.subject, html); + user.changeEmailToken = token; + user.newEmail = emailDto.newEmail; + user.save(); + return user; + } + throw new HttpException('Email already used', HttpStatus.NOT_ACCEPTABLE); + } + throw new HttpException('Email sent if account exist', HttpStatus.UNAUTHORIZED); + } + + public async verifyAndUpdateUserEmail(token: string): Promise<any> { + const user = await this.userModel.findOne({ changeEmailToken: token }).exec(); + if (user) { + user.email = user.newEmail; + user.newEmail = null; + user.changeEmailToken = null; + user.save(); + return user; + } else { + throw new HttpException('Invalid token', HttpStatus.UNAUTHORIZED); + } + } + public async changeUserPassword(userId: string, oldPassword: string, newPassword: string): Promise<any> { const user = await this.findById(userId, true); const arePasswordEqual = await this.comparePassword(oldPassword, user.password);