diff --git a/package-lock.json b/package-lock.json index 9cbd9ccb691588704b567e79dd12534926bcd2d6..f17f7e9a4f8289237269c9e65597749e5ecbd160 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2711,8 +2711,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "at-least-node": { "version": "1.0.0", @@ -3724,7 +3723,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -4726,8 +4724,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, "delegates": { "version": "1.0.0", @@ -6009,13 +6006,12 @@ } }, "form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dev": true, + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz", + "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==", "requires": { "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", + "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, @@ -11360,6 +11356,17 @@ "uuid": "^3.3.2" }, "dependencies": { + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", diff --git a/package.json b/package.json index 35186b8b3707f6b468d07db0d8bcabf1f294e6b1..939149dd38dac70d8bc804a2f26b17a844f6663f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "class-validator": "^0.12.2", "dotenv": "^8.2.0", "ejs": "^3.1.5", + "form-data": "^3.0.0", "luxon": "^1.25.0", "mongoose": "^5.10.15", "passport": "^0.4.1", diff --git a/src/configuration/config.ts b/src/configuration/config.ts index 9cc7f1d62e222b124eebb7f4acfbcbb33275000e..b6d29acca6ec81c264ea73c4c482790a914a3c36 100644 --- a/src/configuration/config.ts +++ b/src/configuration/config.ts @@ -17,5 +17,9 @@ export const config = { ejs: 'changeEmail.ejs', json: 'changeEmail.json', }, + resetPassword: { + ejs: 'resetPassword.ejs', + json: 'resetPassword.json', + }, }, }; diff --git a/src/mailer/mail-templates/resetPassword.ejs b/src/mailer/mail-templates/resetPassword.ejs new file mode 100644 index 0000000000000000000000000000000000000000..7143e2421b42f6cf5acd2b6b81a7d4372d8e847b --- /dev/null +++ b/src/mailer/mail-templates/resetPassword.ejs @@ -0,0 +1,12 @@ +Bonjour<br /> +<br /> +Vous avez demandé une réinitialisation de votre mot de passe pour le +<em>Réseau des Acteurs de la Médiation Numérique de la Métropole de Lyon</em>. Pour changer de mot de passe, merci de +cliquer sur le lien suivant : +<a + href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/reset-password?token=<%= token %>" + >ce lien</a +><br /> +Si vous n'avez pas demander de réinitiallisation de votre mot de passe, merci d'ignorer cet email. +<br /> +Ce mail est un mail automatique. Merci de ne pas y répondre. diff --git a/src/mailer/mail-templates/resetPassword.json b/src/mailer/mail-templates/resetPassword.json new file mode 100644 index 0000000000000000000000000000000000000000..c5fe69522794c8f40ea20e57eb340aec8e9728f5 --- /dev/null +++ b/src/mailer/mail-templates/resetPassword.json @@ -0,0 +1,3 @@ +{ + "subject": "Réinitialisation de mot de passe" +} diff --git a/src/mailer/mailer.service.ts b/src/mailer/mailer.service.ts index 0862f91eec026235b6774ee6dee91977a0cde77d..5dbe73f2fbce258441e1695bf88d9caf8dd50387 100644 --- a/src/mailer/mailer.service.ts +++ b/src/mailer/mailer.service.ts @@ -2,6 +2,7 @@ import { HttpService, Injectable, Logger } from '@nestjs/common'; import { AxiosResponse } from 'axios'; import * as fs from 'fs'; import * as path from 'path'; +import * as FormData from 'form-data'; import { ConfigurationService } from '../configuration/configuration.service'; @Injectable() export class MailerService { @@ -20,22 +21,27 @@ export class MailerService { * @param {string} html * @param {string} text */ - public send(to: string, subject: string, html: string): Promise<AxiosResponse<any>> { + public async send(to: string, subject: string, html: string): Promise<AxiosResponse<any>> { + const formData = new FormData(); const data = JSON.stringify({ - from: this.config.from, + // eslint-disable-next-line camelcase + from_email: this.config.from, // eslint-disable-next-line camelcase from_name: this.config.from_name, - to: to, + to: [{ email: to }], subject: subject, content: html, }); + formData.append('metadata', data); + const contentLength = formData.getLengthSync(); Logger.log(`Send mail : ${subject}`, 'Mailer'); return new Promise((resolve, reject) => { this.httpService - .post(process.env.MAIL_URL, data, { + .post(process.env.MAIL_URL, formData, { headers: { - 'Content-Type': 'application/json', + 'Content-Length': contentLength, Authorization: 'Bearer ' + process.env.MAIL_TOKEN, + ...formData.getHeaders(), }, }) .subscribe( diff --git a/src/users/change-password.dto.ts b/src/users/change-password.dto.ts index 25ff3cc07eef7d4866593b93e390f30e23e5ea1d..73e54f82c21801158042fc060d76ed969e20d5c1 100644 --- a/src/users/change-password.dto.ts +++ b/src/users/change-password.dto.ts @@ -1,6 +1,14 @@ -import { IsNotEmpty } from 'class-validator'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; export class PasswordChangeDto { - @IsNotEmpty() readonly newPassword: string; - @IsNotEmpty() readonly oldPassword: string; + @ApiProperty({ type: String }) + @IsNotEmpty() + @IsString() + readonly newPassword: string; + + @ApiProperty({ type: String }) + @IsNotEmpty() + @IsString() + readonly oldPassword: string; } diff --git a/src/users/create-user.dto.ts b/src/users/create-user.dto.ts index c94b2bd1147be3fbad0ce3c8becf62f06998735f..477d79a2a56bef1bdb62a325e4877c12957792e8 100644 --- a/src/users/create-user.dto.ts +++ b/src/users/create-user.dto.ts @@ -1,6 +1,14 @@ -import { IsEmail, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; export class CreateUserDto { - @IsNotEmpty() readonly password: string; - @IsNotEmpty() @IsEmail() email: string; + @ApiProperty({ type: String }) + @IsNotEmpty() + @IsString() + readonly password: string; + + @IsNotEmpty() + @IsEmail() + @ApiProperty({ type: String }) + email: string; } diff --git a/src/users/reset-password-apply.dto.ts b/src/users/reset-password-apply.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..7fd1ead4fe52ed3831fb8883b6fd3cbe597b8099 --- /dev/null +++ b/src/users/reset-password-apply.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class PasswordResetApplyDto { + @ApiProperty({ type: String }) + @IsNotEmpty() + @IsString() + readonly password: string; + @ApiProperty({ type: String }) + @IsNotEmpty() + @IsString() + readonly token: string; +} diff --git a/src/users/reset-password.dto.ts b/src/users/reset-password.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f3e8e1b14e784f835316063e35627e07559fc7b --- /dev/null +++ b/src/users/reset-password.dto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty, IsEmail } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class PasswordResetDto { + @ApiProperty({ type: String, example: 'toto@mii.com' }) + @IsNotEmpty() + @IsEmail() + readonly email: string; +} diff --git a/src/users/user.interface.ts b/src/users/user.interface.ts index 704569733e0657f065a6598cdc4dcfc7bcdf1954..93aec2489ff596ca7894df6cd4dc8ca2de07fbbd 100644 --- a/src/users/user.interface.ts +++ b/src/users/user.interface.ts @@ -6,6 +6,7 @@ export interface IUser extends Document { password: string; emailVerified: boolean; 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 c41262a381bd19c00b266be3a8345cb91a9c2c51..b429332fe8b17d06eb53488ccf6285ffb7224d19 100644 --- a/src/users/user.schema.ts +++ b/src/users/user.schema.ts @@ -14,6 +14,9 @@ export class User { @Prop({ default: null }) validationToken: string; + @Prop({ default: null }) + resetPasswordToken: string; + @Prop({ enum: [UserRole.admin, UserRole.user], default: UserRole.user }) role: number; diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 9742d630f379c944bccd3fc94b13f510b34082f8..e37378c591129af0f1e7ba5577eee6ac31d1b235 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -4,6 +4,8 @@ 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'; import { UsersService } from './users.service'; @Controller('users') @@ -61,4 +63,16 @@ export class UsersController { 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) { + return this.usersService.sendResetPasswordEmail(passwordReset.email); + } + + @Post('reset-password/apply') + @ApiResponse({ status: 200, description: 'Email sent if account exist' }) + public async resetPasswordApply(@Body() passwordResetApplyDto: PasswordResetApplyDto) { + return this.usersService.validatePasswordResetToken(passwordResetApplyDto.password, passwordResetApplyDto.token); + } } diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index ced92c8a244f82ee32f73992a14ada14fe34136f..c8e1e0c15d4ed0239e868b76151b887da19ceaf9 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -41,6 +41,7 @@ describe('UsersService', () => { password: '$2a$12$vLQjJ9zAWyUwiXLeQDa6w.yazDArYIpf2WnQF1jRHOjBxADEjUEA3', newEmail: '', changeEmailToken: '', + resetPasswordToken: null, }; const userDto: CreateUserDto = { email: 'jacques.dupont@mii.com', password: 'test1A!!' }; //NOSONAR jest.spyOn(service, 'create').mockImplementation(async (): Promise<User> => result); @@ -76,6 +77,7 @@ describe('UsersService', () => { role: 0, newEmail: '', changeEmailToken: '', + resetPasswordToken: null, }; const loginDto: LoginDto = { email: 'jacques.dupont@mii.com', password: 'test1A!!' }; //NOSONAR jest.spyOn(service, 'findByLogin').mockImplementation(async (): Promise<User> => result); @@ -132,6 +134,7 @@ describe('UsersService', () => { password: '$2a$12$vLQjJ9zAWyUwiXLeQDa6w.yazDArYIpf2WnQF1jRHOjBxADEjUEA3', role: 0, newEmail: 'test.dupont@mail.com', + resetPasswordToken: '', changeEmailToken: '9bb3542bdc5ca8801ad4cee00403c1052bc95dee768dcbb65b1f719870578ed79f71f52fdc3e6bf02fd200a72b8b6f56fc26950df30c8cd7e427a485f80181b9', }; @@ -159,24 +162,71 @@ describe('UsersService', () => { password: '$2a$12$vLQjJ9zAWyUwiXLeQDa6w.yazDArYIpf2WnQF1jRHOjBxADEjUEA3', role: 0, newEmail: '', + resetPasswordToken: '', changeEmailToken: '', }; - const token: string = + 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: string = '9bb3542bdc5ca8801aa72b8b6f56fc26950df30c8cd7e427a485f80181b9FAKETOKEN'; //NOSONAR + 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); + jest.spyOn(service, 'sendResetPasswordEmail').mockImplementation(async (): Promise<HttpException> => result); + expect(await service.sendResetPasswordEmail('test@mii.com')).toBe(result); + }); + + it('should send email', async () => { + const result = new HttpException('Email sent if account exist', HttpStatus.OK); + jest.spyOn(service, 'sendResetPasswordEmail').mockImplementation(async (): Promise<HttpException> => result); + expect(await service.sendResetPasswordEmail('test@mii.com')).toBe(result); + }); + }); + + describe('validatePasswordResetToken', () => { + it('should not validate new password: token does`nt exist', async () => { + const result = new HttpException('Invalid token', HttpStatus.UNAUTHORIZED); + jest.spyOn(service, 'validatePasswordResetToken').mockImplementation(async (): Promise<HttpException> => result); + expect( + await service.validatePasswordResetToken( + 'test@mii.com', + '5def4cb41106f89c212679e164911776618bd529e4f78e2883f7dd01776612a1b4a2ad7edabf2a3e3638aa605966c7a4b69d5f07d9617334e58332ba5f9305' + ) + ).toBe(result); + }); + + it('should not validate new password: weak password', async () => { + const result = new HttpException( + 'Weak password, it must contain ne lowercase alphabetical character, one uppercase alphabetical character, one numeric character, one special character and be eight characters or longer', + HttpStatus.UNPROCESSABLE_ENTITY + ); + jest.spyOn(service, 'validatePasswordResetToken').mockImplementation(async (): Promise<HttpException> => result); + expect( + await service.validatePasswordResetToken( + 'test@mii.com', + '5def4cb41106f89c212679e164911776618bd529e4f78e2883f7dd01776612a1b4a2ad7edabf2a3e3638aa605966c7a4b69d5f07d9617334e58332ba5f9305a6' + ) + ).toBe(result); + }); - // it('should not change email', async () => { - // const result = new HttpException('Invalid token', HttpStatus.UNAUTHORIZED); - // jest.spyOn(service, 'changeUserEmail').mockImplementation(async (): Promise<HttpException> => result); - // expect(await service.changeUserEmail('test@test.fr', 'oldTest@test.fr')).toBe(result); - // }); + it('should validate new password', async () => { + const result = new HttpException('Password Reset', HttpStatus.OK); + jest.spyOn(service, 'validatePasswordResetToken').mockImplementation(async (): Promise<HttpException> => result); + expect( + await service.validatePasswordResetToken( + 'test@mii.com', + '5def4cb41106f89c212679e164911776618bd529e4f78e2883f7dd01776612a1b4a2ad7edabf2a3e3638aa605966c7a4b69d5f07d9617334e58332ba5f9305a6' + ) + ).toBe(result); + }); }); }); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 2c17d0e3518917ad73eafed52d2818982df2c173..d05f8d5dea0d016dae3a2648c8838826dbb89d46 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -187,4 +187,52 @@ export class UsersService { user.password = await this.hashPassword(newPassword); user.save(); } + + /** + * Send reset password email based on ejs template + * @param email string + */ + public async sendResetPasswordEmail(email: string): Promise<HttpException> { + const user = await this.findOne(email); + if (user) { + const config = this.mailerService.config; + const ejsPath = this.mailerService.getTemplateLocation(config.templates.resetPassword.ejs); + const jsonConfig = this.mailerService.loadJsonConfig(config.templates.resetPassword.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); + + // Save token + user.resetPasswordToken = token; + user.save(); + } + throw new HttpException('Email sent if account exist', HttpStatus.OK); + } + + /** + * Change password with the given token and password + * Token existence and password strength are verified + * @param password string + * @param token string + */ + public async validatePasswordResetToken(password: string, token: string): Promise<HttpException> { + const user = await this.userModel.findOne({ resetPasswordToken: token }).exec(); + if (user) { + if (!this.isStrongPassword(password)) { + throw new HttpException( + 'Weak password, it must contain ne lowercase alphabetical character, one uppercase alphabetical character, one numeric character, one special character and be eight characters or longer', + HttpStatus.UNPROCESSABLE_ENTITY + ); + } + user.password = await this.hashPassword(password); + user.resetPasswordToken = null; + user.save(); + throw new HttpException('Password Reset', HttpStatus.OK); + } + throw new HttpException('Invalid token', HttpStatus.UNAUTHORIZED); + } }