From ae3b8d1eb3e409cee1c2eab5199ae18bd08560fd Mon Sep 17 00:00:00 2001 From: ezzabuzaid Date: Fri, 6 Dec 2019 20:49:10 +0200 Subject: [PATCH] add verify by email --- src/app/api/portal/portal.routes.ts | 77 +++++++++++++++++---------- src/app/api/portal/portal.spec.ts | 22 +++++--- src/app/api/users/users.model.ts | 6 ++- src/app/api/users/users.service.ts | 8 ++- src/app/core/helpers/constants.ts | 1 + src/app/shared/email/email.service.ts | 10 ++++ src/test/fixture.ts | 2 +- 7 files changed, 86 insertions(+), 40 deletions(-) diff --git a/src/app/api/portal/portal.routes.ts b/src/app/api/portal/portal.routes.ts index 9cdd620f..b25ea25b 100644 --- a/src/app/api/portal/portal.routes.ts +++ b/src/app/api/portal/portal.routes.ts @@ -5,9 +5,11 @@ import { Request, Response } from 'express'; import usersService from '@api/users/users.service'; import { UsersSchema } from '@api/users'; import { Body } from '@lib/mongoose'; -import { EmailService } from '@shared/email'; +import { EmailService, fakeEmail } from '@shared/email'; import { AppUtils } from '@core/utils'; import { PortalHelper } from './portal.helper'; +import { TokenExpiredError } from 'jsonwebtoken'; +import { Auth } from './auth'; export interface IRefreshTokenBody { token: string; @@ -43,20 +45,27 @@ export class PortalRoutes { // other instances will now it, (new column in users collection) const { token, refreshToken } = req.body as IRefreshTokenBody; // NOTE: if it's not valid it will implicity thrown an error - await tokenService.decodeToken(refreshToken); + const decodedRefreshToken = await tokenService.decodeToken(refreshToken); - const decodedToken = await tokenService.decodeToken(token); - // TODO: find a way to know that the token is really for this user (unique device id) generate one in - // login and save it in browser - if (AppUtils.not(tokenService.isTokenExpired(decodedToken))) { - throw new ErrorResponse(translate('not_allowed'), NetworkStatus.NOT_ACCEPTABLE); + try { + await tokenService.decodeToken(token); + } catch (error) { + if (error instanceof TokenExpiredError) { + // TODO: find a way to know that the token is really for this user (unique device id) generate one in + // login and save it in browser + const user = await throwIfNotExist({ _id: decodedRefreshToken.id }); + // TODO: invalidate the refresh token directly after new one is generated + const response = new SuccessResponse({ + token: PortalHelper.generateToken(user.id, user.role), + refreshToken: PortalHelper.generateRefreshToken(user.id) + }); + return res.status(response.code).json(response); + } else { + throw error; + } } - await throwIfNotExist({ _id: decodedToken.id }); - const response = new SuccessResponse({ - token: PortalHelper.generateToken(decodedToken.id, decodedToken.role), - refreshToken: PortalHelper.generateRefreshToken(decodedToken.id) - }); - return res.status(response.code).json(response); + throw new ErrorResponse(translate('not_allowed'), NetworkStatus.NOT_ACCEPTABLE); + } @Post(Constants.Endpoints.FORGET_PASSWORD) @@ -64,34 +73,44 @@ export class PortalRoutes { const { email } = req.body as Body; const entity = await throwIfNotExist({ email }); const token = tokenService.generateToken({ id: entity.id, role: entity.role }, { expiresIn: '1h' }); - const message = { - from: 'ezzabuzaid@gmail.com', - to: 'ezzabuzaid@hotmail.com', - subject: 'Nodemailer is unicode friendly ✔', - text: 'Hello to myself!', - html: `Click on this link to complete Reset Password` - }; - const url = await EmailService.sendEmail(message); + const url = await EmailService.sendEmail(fakeEmail()); const response = new SuccessResponse({ url }); res.status(response.code).json(response); } @Post(Constants.Endpoints.RESET_PASSWORD) public async resetPassword(req: Request, res: Response) { + // REVIEW if anyone get the token can change the user password, + // so we need to know that the user who really did that by answering a specifc questions, doing 2FA + // the attempts should be limited to 3 times, after that he need to re do the process again, + // if the procces faild 3 times, the account should be locked, and he need to call the support for that const { password } = req.body as Body; const decodedToken = await tokenService.decodeToken(req.headers.authorization); await usersService.update({ id: decodedToken.id, body: { password } }); const response = new SuccessResponse(null); - const message = { - from: 'ezzabuzaid@gmail.com', - to: 'ezzabuzaid@hotmail.com', - subject: 'Nodemailer is unicode friendly ✔', - text: 'Hello to myself!', - html: `Password rest successfully` - }; - await EmailService.sendEmail(message); + await EmailService.sendEmail(fakeEmail()); + res.status(response.code).json(response); + } + + // TODO: the user should verify his email and phonenumber + @Post(Constants.Endpoints.VERIFY_ACCOUNT) + public async verify(req: Request, res: Response) { + const { token } = req.query; + const decodedToken = await tokenService.decodeToken(token); + await usersService.update({ id: decodedToken.id, body: { verified: true } }); + const response = new SuccessResponse(null); res.status(response.code).json(response); } + + @Post(Constants.Endpoints.VERIFY_ACCOUNT, Auth.isAuthenticated) + public async sendVerificationEmail(req: Request, res: Response) { + const { token } = req.query; + const decodedToken = await tokenService.decodeToken(token); + await usersService.update({ id: decodedToken.id, body: { verified: true } }); + const response = new SuccessResponse(null); + res.status(response.code).json(response); + } + } async function throwIfNotExist(query: Partial & { _id: string }>, message = 'not_exist') { diff --git a/src/app/api/portal/portal.spec.ts b/src/app/api/portal/portal.spec.ts index b0b3347a..ee8246a6 100644 --- a/src/app/api/portal/portal.spec.ts +++ b/src/app/api/portal/portal.spec.ts @@ -2,7 +2,7 @@ import { UsersSchema, ERoles } from '@api/users'; import { Body } from '@lib/mongoose'; import { superAgent } from '@test/index'; import { Constants, NetworkStatus, tokenService } from '@core/helpers'; -import { getUri, generateExpiredToken, UserUtilityFixture } from '@test/fixture'; +import { getUri, generateExpiredToken, UserUtilityFixture, sendRequest } from '@test/fixture'; import { AppUtils } from '@core/utils'; import { IRefreshTokenBody } from './portal.routes'; import { PortalHelper } from './portal.helper'; @@ -53,10 +53,10 @@ describe('Reset password ', () => { }); describe('Refresh Token', () => { - async function getResponse(invalidToken, token = null) { - const req = (await superAgent).post(REFRESH_TOKEN); - return req.send({ refreshToken: invalidToken, token } as IRefreshTokenBody); - } + const getResponse = (invalidToken: string, token: string = null) => { + return sendRequest(REFRESH_TOKEN, { refreshToken: invalidToken, token } as IRefreshTokenBody); + }; + const INVALID_TOKEN = 'invalid.token.refuesed'; it('should throw if the refresh token is expired', async () => { const res = await getResponse(generateExpiredToken()); @@ -66,7 +66,7 @@ describe('Refresh Token', () => { const res = await getResponse(INVALID_TOKEN); expect(res.status).toBe(NetworkStatus.BAD_REQUEST); }); - it('should throw if the refresh token is invalid', async () => { + it('should throw if the token is invalid', async () => { const res = await getResponse( PortalHelper.generateRefreshToken(null), INVALID_TOKEN @@ -80,7 +80,15 @@ describe('Refresh Token', () => { ); expect(res.status).toBe(NetworkStatus.NOT_ACCEPTABLE); }); - + it('should not throw if the token is expired', async () => { + const userUtiltiy = new UserUtilityFixture(); + await userUtiltiy.createUser(); + const res = await getResponse( + PortalHelper.generateRefreshToken(userUtiltiy.user.id), + generateExpiredToken() + ); + expect(res.status).toBe(NetworkStatus.OK); + }); // it.todo('should retrun with valid token and refresh token', async () => { // const userUtility = new UserUtilityFixture(); // const user = await userUtility.createUser(); diff --git a/src/app/api/users/users.model.ts b/src/app/api/users/users.model.ts index 3d05be75..6cc85e92 100644 --- a/src/app/api/users/users.model.ts +++ b/src/app/api/users/users.model.ts @@ -46,11 +46,13 @@ export class UsersSchema { ], unique: true, }) public mobile: string; - + @Field({ + pure: true, + default: true + }) public verified: boolean; public comparePassword(candidatePassword: string) { return HashService.comparePassword(candidatePassword, this.password); } - constructor(obj: Body) { } } export const UsersModel = BaseModel(UsersSchema); diff --git a/src/app/api/users/users.service.ts b/src/app/api/users/users.service.ts index 3b073e77..fdc6e593 100644 --- a/src/app/api/users/users.service.ts +++ b/src/app/api/users/users.service.ts @@ -1,14 +1,20 @@ import { UsersSchema } from './users.model'; import { CrudService } from '@shared/crud/crud.service'; import { usersRepo } from '.'; +import { EmailService, fakeEmail } from '@shared/email'; // TODO: provide an option for strict schema checking to not allowed -// an additional attribute to come, morever check the types +// an additional attribute to come, moreover check the types // TODO: Validate the body to meet the schema exactly using reflect metadata class UserService extends CrudService { constructor() { super(usersRepo, { unique: ['username', 'email'], + create: { + post(entity) { + EmailService.sendEmail(fakeEmail()); + } + } }); } } diff --git a/src/app/core/helpers/constants.ts b/src/app/core/helpers/constants.ts index 694a050a..5d296984 100644 --- a/src/app/core/helpers/constants.ts +++ b/src/app/core/helpers/constants.ts @@ -12,6 +12,7 @@ export namespace Constants { public static readonly FORGET_PASSWORD = 'forgetpassword'; public static readonly RESET_PASSWORD = 'resetpassword'; public static readonly REFRESH_TOKEN = 'refreshtoken'; + public static readonly VERIFY_ACCOUNT = 'verifyaccount'; } export class Schemas { public static readonly favorites = 'favorites'; diff --git a/src/app/shared/email/email.service.ts b/src/app/shared/email/email.service.ts index 835ae4f3..7795330b 100644 --- a/src/app/shared/email/email.service.ts +++ b/src/app/shared/email/email.service.ts @@ -17,3 +17,13 @@ export class EmailService { return nodemailer.getTestMessageUrl(info); } } + +export function fakeEmail(): Mail.Options { + return { + from: 'ezzabuzaid@gmail.com', + to: 'ezzabuzaid@hotmail.com', + subject: 'Nodemailer is unicode friendly ✔', + text: 'Hello to myself!', + html: `Password rest successfully` + }; +} diff --git a/src/test/fixture.ts b/src/test/fixture.ts index e3fdde9b..b65006a7 100644 --- a/src/test/fixture.ts +++ b/src/test/fixture.ts @@ -17,7 +17,7 @@ export function generateExpiredToken() { } export class UserUtilityFixture { - private user = { + public user = { id: null, token: null };