Skip to content
Snippets Groups Projects
Commit e7ae157a authored by Hugo SUBTIL's avatar Hugo SUBTIL
Browse files

Merge remote-tracking branch 'origin/dev' into feat/change-email

parents cbeda30d 7302113a
No related branches found
No related tags found
3 merge requests!27Recette,!26Dev,!9Feat/change email
...@@ -2711,8 +2711,7 @@ ...@@ -2711,8 +2711,7 @@
"asynckit": { "asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
"dev": true
}, },
"at-least-node": { "at-least-node": {
"version": "1.0.0", "version": "1.0.0",
...@@ -3724,7 +3723,6 @@ ...@@ -3724,7 +3723,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"requires": { "requires": {
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
} }
...@@ -4726,8 +4724,7 @@ ...@@ -4726,8 +4724,7 @@
"delayed-stream": { "delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
"dev": true
}, },
"delegates": { "delegates": {
"version": "1.0.0", "version": "1.0.0",
...@@ -6009,13 +6006,12 @@ ...@@ -6009,13 +6006,12 @@
} }
}, },
"form-data": { "form-data": {
"version": "2.3.3", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==",
"dev": true,
"requires": { "requires": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.6", "combined-stream": "^1.0.8",
"mime-types": "^2.1.12" "mime-types": "^2.1.12"
} }
}, },
...@@ -11360,6 +11356,17 @@ ...@@ -11360,6 +11356,17 @@
"uuid": "^3.3.2" "uuid": "^3.3.2"
}, },
"dependencies": { "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": { "qs": {
"version": "6.5.2", "version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
......
...@@ -34,6 +34,7 @@ ...@@ -34,6 +34,7 @@
"class-validator": "^0.12.2", "class-validator": "^0.12.2",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"ejs": "^3.1.5", "ejs": "^3.1.5",
"form-data": "^3.0.0",
"luxon": "^1.25.0", "luxon": "^1.25.0",
"mongoose": "^5.10.15", "mongoose": "^5.10.15",
"passport": "^0.4.1", "passport": "^0.4.1",
......
...@@ -17,5 +17,9 @@ export const config = { ...@@ -17,5 +17,9 @@ export const config = {
ejs: 'changeEmail.ejs', ejs: 'changeEmail.ejs',
json: 'changeEmail.json', json: 'changeEmail.json',
}, },
resetPassword: {
ejs: 'resetPassword.ejs',
json: 'resetPassword.json',
},
}, },
}; };
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.
{
"subject": "Réinitialisation de mot de passe"
}
...@@ -2,6 +2,7 @@ import { HttpService, Injectable, Logger } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { HttpService, Injectable, Logger } from '@nestjs/common';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as FormData from 'form-data';
import { ConfigurationService } from '../configuration/configuration.service'; import { ConfigurationService } from '../configuration/configuration.service';
@Injectable() @Injectable()
export class MailerService { export class MailerService {
...@@ -20,22 +21,27 @@ export class MailerService { ...@@ -20,22 +21,27 @@ export class MailerService {
* @param {string} html * @param {string} html
* @param {string} text * @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({ const data = JSON.stringify({
from: this.config.from, // eslint-disable-next-line camelcase
from_email: this.config.from,
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
from_name: this.config.from_name, from_name: this.config.from_name,
to: to, to: [{ email: to }],
subject: subject, subject: subject,
content: html, content: html,
}); });
formData.append('metadata', data);
const contentLength = formData.getLengthSync();
Logger.log(`Send mail : ${subject}`, 'Mailer'); Logger.log(`Send mail : ${subject}`, 'Mailer');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.httpService this.httpService
.post(process.env.MAIL_URL, data, { .post(process.env.MAIL_URL, formData, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Length': contentLength,
Authorization: 'Bearer ' + process.env.MAIL_TOKEN, Authorization: 'Bearer ' + process.env.MAIL_TOKEN,
...formData.getHeaders(),
}, },
}) })
.subscribe( .subscribe(
......
import { IsNotEmpty } from 'class-validator'; import { IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class PasswordChangeDto { export class PasswordChangeDto {
@IsNotEmpty() readonly newPassword: string; @ApiProperty({ type: String })
@IsNotEmpty() readonly oldPassword: string; @IsNotEmpty()
@IsString()
readonly newPassword: string;
@ApiProperty({ type: String })
@IsNotEmpty()
@IsString()
readonly oldPassword: string;
} }
import { IsEmail, IsNotEmpty } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class CreateUserDto { export class CreateUserDto {
@IsNotEmpty() readonly password: string; @ApiProperty({ type: String })
@IsNotEmpty() @IsEmail() email: string; @IsNotEmpty()
@IsString()
readonly password: string;
@IsNotEmpty()
@IsEmail()
@ApiProperty({ type: String })
email: string;
} }
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;
}
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;
}
...@@ -6,6 +6,7 @@ export interface IUser extends Document { ...@@ -6,6 +6,7 @@ export interface IUser extends Document {
password: string; password: string;
emailVerified: boolean; emailVerified: boolean;
validationToken: string; validationToken: string;
resetPasswordToken: string;
role: number; role: number;
changeEmailToken: string; changeEmailToken: string;
newEmail: string; newEmail: string;
......
...@@ -14,6 +14,9 @@ export class User { ...@@ -14,6 +14,9 @@ export class User {
@Prop({ default: null }) @Prop({ default: null })
validationToken: string; validationToken: string;
@Prop({ default: null })
resetPasswordToken: string;
@Prop({ enum: [UserRole.admin, UserRole.user], default: UserRole.user }) @Prop({ enum: [UserRole.admin, UserRole.user], default: UserRole.user })
role: number; role: number;
......
...@@ -4,6 +4,8 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; ...@@ -4,6 +4,8 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { PasswordChangeDto } from './change-password.dto'; import { PasswordChangeDto } from './change-password.dto';
import { EmailChangeDto } from './change-email.dto'; import { EmailChangeDto } from './change-email.dto';
import { CreateUserDto } from './create-user.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'; import { UsersService } from './users.service';
@Controller('users') @Controller('users')
...@@ -61,4 +63,16 @@ export class UsersController { ...@@ -61,4 +63,16 @@ export class UsersController {
public async verifyAndUpdateEmail(@Request() req, @Query('token') token: string) { public async verifyAndUpdateEmail(@Request() req, @Query('token') token: string) {
return this.usersService.verifyAndUpdateUserEmail(token); 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);
}
} }
...@@ -41,6 +41,7 @@ describe('UsersService', () => { ...@@ -41,6 +41,7 @@ describe('UsersService', () => {
password: '$2a$12$vLQjJ9zAWyUwiXLeQDa6w.yazDArYIpf2WnQF1jRHOjBxADEjUEA3', password: '$2a$12$vLQjJ9zAWyUwiXLeQDa6w.yazDArYIpf2WnQF1jRHOjBxADEjUEA3',
newEmail: '', newEmail: '',
changeEmailToken: '', changeEmailToken: '',
resetPasswordToken: null,
}; };
const userDto: CreateUserDto = { email: 'jacques.dupont@mii.com', password: 'test1A!!' }; //NOSONAR const userDto: CreateUserDto = { email: 'jacques.dupont@mii.com', password: 'test1A!!' }; //NOSONAR
jest.spyOn(service, 'create').mockImplementation(async (): Promise<User> => result); jest.spyOn(service, 'create').mockImplementation(async (): Promise<User> => result);
...@@ -76,6 +77,7 @@ describe('UsersService', () => { ...@@ -76,6 +77,7 @@ describe('UsersService', () => {
role: 0, role: 0,
newEmail: '', newEmail: '',
changeEmailToken: '', changeEmailToken: '',
resetPasswordToken: null,
}; };
const loginDto: LoginDto = { email: 'jacques.dupont@mii.com', password: 'test1A!!' }; //NOSONAR const loginDto: LoginDto = { email: 'jacques.dupont@mii.com', password: 'test1A!!' }; //NOSONAR
jest.spyOn(service, 'findByLogin').mockImplementation(async (): Promise<User> => result); jest.spyOn(service, 'findByLogin').mockImplementation(async (): Promise<User> => result);
...@@ -132,6 +134,7 @@ describe('UsersService', () => { ...@@ -132,6 +134,7 @@ describe('UsersService', () => {
password: '$2a$12$vLQjJ9zAWyUwiXLeQDa6w.yazDArYIpf2WnQF1jRHOjBxADEjUEA3', password: '$2a$12$vLQjJ9zAWyUwiXLeQDa6w.yazDArYIpf2WnQF1jRHOjBxADEjUEA3',
role: 0, role: 0,
newEmail: 'test.dupont@mail.com', newEmail: 'test.dupont@mail.com',
resetPasswordToken: '',
changeEmailToken: changeEmailToken:
'9bb3542bdc5ca8801ad4cee00403c1052bc95dee768dcbb65b1f719870578ed79f71f52fdc3e6bf02fd200a72b8b6f56fc26950df30c8cd7e427a485f80181b9', '9bb3542bdc5ca8801ad4cee00403c1052bc95dee768dcbb65b1f719870578ed79f71f52fdc3e6bf02fd200a72b8b6f56fc26950df30c8cd7e427a485f80181b9',
}; };
...@@ -159,24 +162,71 @@ describe('UsersService', () => { ...@@ -159,24 +162,71 @@ describe('UsersService', () => {
password: '$2a$12$vLQjJ9zAWyUwiXLeQDa6w.yazDArYIpf2WnQF1jRHOjBxADEjUEA3', password: '$2a$12$vLQjJ9zAWyUwiXLeQDa6w.yazDArYIpf2WnQF1jRHOjBxADEjUEA3',
role: 0, role: 0,
newEmail: '', newEmail: '',
resetPasswordToken: '',
changeEmailToken: '', changeEmailToken: '',
}; };
const token: string = const token =
'9bb3542bdc5ca8801ad4cee00403c1052bc95dee768dcbb65b1f719870578ed79f71f52fdc3e6bf02fd200a72b8b6f56fc26950df30c8cd7e427a485f80181b9'; //NOSONAR '9bb3542bdc5ca8801ad4cee00403c1052bc95dee768dcbb65b1f719870578ed79f71f52fdc3e6bf02fd200a72b8b6f56fc26950df30c8cd7e427a485f80181b9'; //NOSONAR
jest.spyOn(service, 'verifyAndUpdateUserEmail').mockImplementation(async (): Promise<User> => result); jest.spyOn(service, 'verifyAndUpdateUserEmail').mockImplementation(async (): Promise<User> => result);
expect(await service.verifyAndUpdateUserEmail(token)).toBe(result); expect(await service.verifyAndUpdateUserEmail(token)).toBe(result);
}); });
it('should not change email', async () => { it('should not change email', async () => {
const result: HttpException = new HttpException('Invalid token', HttpStatus.UNAUTHORIZED); 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); jest.spyOn(service, 'verifyAndUpdateUserEmail').mockImplementation(async (): Promise<any> => result);
expect(await service.verifyAndUpdateUserEmail(token)).toBe(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 () => { it('should validate new password', async () => {
// const result = new HttpException('Invalid token', HttpStatus.UNAUTHORIZED); const result = new HttpException('Password Reset', HttpStatus.OK);
// jest.spyOn(service, 'changeUserEmail').mockImplementation(async (): Promise<HttpException> => result); jest.spyOn(service, 'validatePasswordResetToken').mockImplementation(async (): Promise<HttpException> => result);
// expect(await service.changeUserEmail('test@test.fr', 'oldTest@test.fr')).toBe(result); expect(
// }); await service.validatePasswordResetToken(
'test@mii.com',
'5def4cb41106f89c212679e164911776618bd529e4f78e2883f7dd01776612a1b4a2ad7edabf2a3e3638aa605966c7a4b69d5f07d9617334e58332ba5f9305a6'
)
).toBe(result);
});
}); });
}); });
...@@ -187,4 +187,52 @@ export class UsersService { ...@@ -187,4 +187,52 @@ export class UsersService {
user.password = await this.hashPassword(newPassword); user.password = await this.hashPassword(newPassword);
user.save(); 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);
}
} }
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment