From 9dae3f27df371103b6a9f96924980d2d8d7ba14e Mon Sep 17 00:00:00 2001 From: Muhammad Mustafa Date: Sat, 20 Feb 2021 00:24:29 +0500 Subject: [PATCH] email verification added --- .vscode/launch.json | 17 ++++ package.json | 3 +- src/config/config.js | 1 + src/config/roles.js | 2 +- src/config/tokens.js | 1 + src/controllers/auth.controller.js | 13 +++ src/models/token.model.js | 2 +- src/models/user.model.js | 4 + src/routes/v1/auth.route.js | 8 ++ src/services/auth.service.js | 19 +++++ src/services/email.service.js | 11 ++- src/services/token.service.js | 12 ++- src/validations/auth.validation.js | 14 +++ tests/integration/auth.test.js | 133 ++++++++++++++++++++++++++++- tests/integration/user.test.js | 13 ++- 15 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..265b8053 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch via Yarn", + "runtimeExecutable": "yarn", + "cwd": "${workspaceFolder}", + "runtimeArgs": ["start:debug"], + "port": 5858 + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 0371f5da..1ff09628 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "scripts": { "start": "pm2 start ecosystem.config.json --no-daemon", "dev": "cross-env NODE_ENV=development nodemon src/index.js", - "test": "jest -i", + "dev:debug": "nodemon --inspect-brk=5858 src/index.js", + "test": "jest -i --colors --testEnvironment=node --verbose --coverage --detectOpenHandles", "test:watch": "jest -i --watchAll", "coverage": "jest -i --coverage", "coverage:coveralls": "jest -i --coverage --coverageReporters=text-lcov | coveralls", diff --git a/src/config/config.js b/src/config/config.js index 23dc1faf..d70b436b 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -42,6 +42,7 @@ module.exports = { accessExpirationMinutes: envVars.JWT_ACCESS_EXPIRATION_MINUTES, refreshExpirationDays: envVars.JWT_REFRESH_EXPIRATION_DAYS, resetPasswordExpirationMinutes: 10, + verificationEmailExpirationMinutes: 10, }, email: { smtp: { diff --git a/src/config/roles.js b/src/config/roles.js index fb76ba61..6cfc6d57 100644 --- a/src/config/roles.js +++ b/src/config/roles.js @@ -1,7 +1,7 @@ const roles = ['user', 'admin']; const roleRights = new Map(); -roleRights.set(roles[0], []); +roleRights.set(roles[0], ['verifyEmail']); roleRights.set(roles[1], ['getUsers', 'manageUsers']); module.exports = { diff --git a/src/config/tokens.js b/src/config/tokens.js index 495cbf35..bcc1f59d 100644 --- a/src/config/tokens.js +++ b/src/config/tokens.js @@ -2,6 +2,7 @@ const tokenTypes = { ACCESS: 'access', REFRESH: 'refresh', RESET_PASSWORD: 'resetPassword', + VERIFICATION_EMAIL: 'verificationEmail', }; module.exports = { diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index 7bd2064b..262ba399 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -36,6 +36,17 @@ const resetPassword = catchAsync(async (req, res) => { res.status(httpStatus.NO_CONTENT).send(); }); +const verificationEmail = catchAsync(async (req, res) => { + const verifyEmailToken = await tokenService.generateVerificationEmailToken(req.body.email); + await emailService.sendVerificationEmail(req.body.email, verifyEmailToken); + res.status(httpStatus.NO_CONTENT).send(); +}); + +const verifyEmail = catchAsync(async (req, res) => { + await authService.verifyEmail(req.query.token); + res.status(httpStatus.NO_CONTENT).send(); +}); + module.exports = { register, login, @@ -43,4 +54,6 @@ module.exports = { refreshTokens, forgotPassword, resetPassword, + verificationEmail, + verifyEmail, }; diff --git a/src/models/token.model.js b/src/models/token.model.js index fd8fb9b9..b0f7a31a 100644 --- a/src/models/token.model.js +++ b/src/models/token.model.js @@ -16,7 +16,7 @@ const tokenSchema = mongoose.Schema( }, type: { type: String, - enum: [tokenTypes.REFRESH, tokenTypes.RESET_PASSWORD], + enum: [tokenTypes.REFRESH, tokenTypes.RESET_PASSWORD, tokenTypes.VERIFICATION_EMAIL], required: true, }, expires: { diff --git a/src/models/user.model.js b/src/models/user.model.js index df877935..605479ef 100644 --- a/src/models/user.model.js +++ b/src/models/user.model.js @@ -40,6 +40,10 @@ const userSchema = mongoose.Schema( enum: roles, default: 'user', }, + isEmailVarified: { + type: Boolean, + default: false, + }, }, { timestamps: true, diff --git a/src/routes/v1/auth.route.js b/src/routes/v1/auth.route.js index dbd2cee2..5c778df0 100644 --- a/src/routes/v1/auth.route.js +++ b/src/routes/v1/auth.route.js @@ -2,6 +2,7 @@ const express = require('express'); const validate = require('../../middlewares/validate'); const authValidation = require('../../validations/auth.validation'); const authController = require('../../controllers/auth.controller'); +const auth = require('../../middlewares/auth'); const router = express.Router(); @@ -11,6 +12,13 @@ router.post('/logout', validate(authValidation.logout), authController.logout); router.post('/refresh-tokens', validate(authValidation.refreshTokens), authController.refreshTokens); router.post('/forgot-password', validate(authValidation.forgotPassword), authController.forgotPassword); router.post('/reset-password', validate(authValidation.resetPassword), authController.resetPassword); +router.post( + '/verification-email', + auth('verifyEmail'), + validate(authValidation.verificationEmail), + authController.verificationEmail +); +router.post('/verify-email', validate(authValidation.verifyEmail), authController.verifyEmail); module.exports = router; diff --git a/src/services/auth.service.js b/src/services/auth.service.js index 1652e1d0..a78975e0 100644 --- a/src/services/auth.service.js +++ b/src/services/auth.service.js @@ -70,10 +70,29 @@ const resetPassword = async (resetPasswordToken, newPassword) => { throw new ApiError(httpStatus.UNAUTHORIZED, 'Password reset failed'); } }; +/** + * Verify Email + * @param {string} EmailVerificationToken + * @returns {Promise} + */ +const verifyEmail = async (emailVarificationToken) => { + try { + const emailVarificationTokenDoc = await tokenService.verifyToken(emailVarificationToken, tokenTypes.VERIFICATION_EMAIL); + const user = await userService.getUserById(emailVarificationTokenDoc.user); + if (!user) { + throw new Error(); + } + await Token.deleteMany({ user: user.id, type: tokenTypes.VERIFICATION_EMAIL }); + await userService.updateUserById(user.id, { isEmailVarified: true }); + } catch (error) { + throw new ApiError(httpStatus.UNAUTHORIZED, 'email verification failed'); + } +}; module.exports = { loginUserWithEmailAndPassword, logout, refreshAuth, resetPassword, + verifyEmail, }; diff --git a/src/services/email.service.js b/src/services/email.service.js index a2a0472a..e34f9186 100644 --- a/src/services/email.service.js +++ b/src/services/email.service.js @@ -38,9 +38,18 @@ To reset your password, click on this link: ${resetPasswordUrl} If you did not request any password resets, then ignore this email.`; await sendEmail(to, subject, text); }; - +const sendVerificationEmail = async (to, token) => { + const subject = 'Email Verification'; + // replace this url with the link to the reset password page of your front-end app + const verificationEmailUrl = `http://link-to-app/reset-password?token=${token}`; + const text = `Dear user, + To verify your email, click on this link: ${verificationEmailUrl} + If you did not request any email verification, then ignore this email.`; + await sendEmail(to, subject, text); +}; module.exports = { transport, sendEmail, sendResetPasswordEmail, + sendVerificationEmail, }; diff --git a/src/services/token.service.js b/src/services/token.service.js index f8c207af..3b29874f 100644 --- a/src/services/token.service.js +++ b/src/services/token.service.js @@ -99,11 +99,21 @@ const generateResetPasswordToken = async (email) => { await saveToken(resetPasswordToken, user.id, expires, tokenTypes.RESET_PASSWORD); return resetPasswordToken; }; - +const generateVerificationEmailToken = async (email) => { + const user = await userService.getUserByEmail(email); + if (!user) { + throw new ApiError(httpStatus.NOT_FOUND, 'No users found with this email'); + } + const expires = moment().add(config.jwt.verificationEmailExpirationMinutes, 'minutes'); + const verificationEmailToken = generateToken(user.id, expires); + await saveToken(verificationEmailToken, user.id, expires, tokenTypes.VERIFICATION_EMAIL); + return verificationEmailToken; +}; module.exports = { generateToken, saveToken, verifyToken, generateAuthTokens, generateResetPasswordToken, + generateVerificationEmailToken, }; diff --git a/src/validations/auth.validation.js b/src/validations/auth.validation.js index be688df3..44933b08 100644 --- a/src/validations/auth.validation.js +++ b/src/validations/auth.validation.js @@ -1,3 +1,5 @@ +/* eslint-disable no-unused-vars */ +/* eslint-disable prettier/prettier */ const Joi = require('joi'); const { password } = require('./custom.validation'); @@ -43,6 +45,16 @@ const resetPassword = { }), }; +const verificationEmail = { + body: Joi.object().keys({ + email: Joi.string().required(), + }) +}; +const verifyEmail = { + query: Joi.object().keys({ + token: Joi.string().required(), + }) +}; module.exports = { register, login, @@ -50,4 +62,6 @@ module.exports = { refreshTokens, forgotPassword, resetPassword, + verificationEmail, + verifyEmail, }; diff --git a/tests/integration/auth.test.js b/tests/integration/auth.test.js index b06ea178..02fd5b3a 100644 --- a/tests/integration/auth.test.js +++ b/tests/integration/auth.test.js @@ -13,7 +13,7 @@ const setupTestDB = require('../utils/setupTestDB'); const { User, Token } = require('../../src/models'); const { roleRights } = require('../../src/config/roles'); const { tokenTypes } = require('../../src/config/tokens'); -const { userOne, admin, insertUsers } = require('../fixtures/user.fixture'); +const { userOne, admin, insertUsers, userTwo } = require('../fixtures/user.fixture'); const { userOneAccessToken, adminAccessToken } = require('../fixtures/token.fixture'); setupTestDB(); @@ -33,12 +33,18 @@ describe('Auth routes', () => { const res = await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.CREATED); expect(res.body.user).not.toHaveProperty('password'); - expect(res.body.user).toEqual({ id: expect.anything(), name: newUser.name, email: newUser.email, role: 'user' }); + expect(res.body.user).toEqual({ + id: expect.anything(), + name: newUser.name, + email: newUser.email, + role: 'user', + isEmailVarified: false, + }); const dbUser = await User.findById(res.body.user.id); expect(dbUser).toBeDefined(); expect(dbUser.password).not.toBe(newUser.password); - expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: 'user' }); + expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: 'user', isEmailVarified: false }); expect(res.body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, @@ -91,6 +97,7 @@ describe('Auth routes', () => { name: userOne.name, email: userOne.email, role: userOne.role, + isEmailVarified: false, }); expect(res.body.tokens).toEqual({ @@ -236,7 +243,11 @@ describe('Auth routes', () => { await insertUsers([userOne]); const sendResetPasswordEmailSpy = jest.spyOn(emailService, 'sendResetPasswordEmail'); - await request(app).post('/v1/auth/forgot-password').send({ email: userOne.email }).expect(httpStatus.NO_CONTENT); + await request(app) + .post('/v1/auth/forgot-password') + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send({ email: userOne.email }) + .expect(httpStatus.NO_CONTENT); expect(sendResetPasswordEmailSpy).toHaveBeenCalledWith(userOne.email, expect.any(String)); const resetPasswordToken = sendResetPasswordEmailSpy.mock.calls[0][1]; @@ -347,6 +358,120 @@ describe('Auth routes', () => { .expect(httpStatus.BAD_REQUEST); }); }); + describe('POST /v1/auth/verification-email', () => { + beforeEach(() => { + jest.spyOn(emailService.transport, 'sendMail').mockResolvedValue(); + }); + + test('should return 204 and send verification email to the user', async () => { + await insertUsers([userOne]); + const sendVerificationEmaillSpy = jest.spyOn(emailService, 'sendVerificationEmail'); + + await request(app) + .post('/v1/auth/verification-email') + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send({ email: userOne.email }) + .expect(httpStatus.NO_CONTENT); + + expect(sendVerificationEmaillSpy).toHaveBeenCalledWith(userOne.email, expect.any(String)); + const VerificationEmailToken = sendVerificationEmaillSpy.mock.calls[0][1]; + const dbVerificationEmailTokenDoc = await Token.findOne({ token: VerificationEmailToken, user: userOne._id }); + + expect(dbVerificationEmailTokenDoc).toBeDefined(); + }); + + test('should return 400 if email is missing', async () => { + await insertUsers([userOne]); + + await request(app) + .post('/v1/auth/verification-email') + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send() + .expect(httpStatus.BAD_REQUEST); + }); + test('should return 404 if email does not belong to any user', async () => { + await insertUsers([userOne]); + await request(app) + .post('/v1/auth/verification-email') + .set('Authorization', `Bearer ${userOneAccessToken}`) + .send({ email: userTwo.email }) + .expect(httpStatus.NOT_FOUND); + }); + test('should return 401 error if access token is missing', async () => { + await insertUsers([userOne]); + + await request(app).post('/v1/auth/verification-email').send({ email: userOne.email }).expect(httpStatus.UNAUTHORIZED); + }); + }); + + describe('POST /v1/auth/verify-email', () => { + test('should return 204 and verify the email', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.verificationEmailExpirationMinutes, 'minutes'); + const verificationEmailToken = tokenService.generateToken(userOne._id, expires); + await tokenService.saveToken(verificationEmailToken, userOne._id, expires, tokenTypes.VERIFICATION_EMAIL); + + await request(app) + .post('/v1/auth/verify-email') + .query({ token: verificationEmailToken }) + .send() + .expect(httpStatus.NO_CONTENT); + + const dbUser = await User.findById(userOne._id); + + expect(dbUser.isEmailVarified).toBe(true); + + const dbVerificationEmailTokenCount = await Token.countDocuments({ + user: userOne._id, + type: tokenTypes.VERIFICATION_EMAIL, + }); + expect(dbVerificationEmailTokenCount).toBe(0); + }); + + test('should return 400 if email verification token is missing', async () => { + await insertUsers([userOne]); + + await request(app).post('/v1/auth/verify-email').send().expect(httpStatus.BAD_REQUEST); + }); + + test('should return 401 if verification email token is blacklisted', async () => { + await insertUsers([userOne]); + const expires = moment().add(config.jwt.verificationEmailExpirationMinutes, 'minutes'); + const verificationEmailToken = tokenService.generateToken(userOne._id, expires); + await tokenService.saveToken(verificationEmailToken, userOne._id, expires, tokenTypes.VERIFICATION_EMAIL, true); + + await request(app) + .post('/v1/auth/verify-email') + .query({ token: verificationEmailToken }) + .send() + .expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 401 if email verification token is expired', async () => { + await insertUsers([userOne]); + const expires = moment().subtract(1, 'minutes'); + const verificationEmailToken = tokenService.generateToken(userOne._id, expires); + await tokenService.saveToken(verificationEmailToken, userOne._id, expires, tokenTypes.VERIFICATION_EMAIL); + + await request(app) + .post('/v1/auth/verify-email') + .query({ token: verificationEmailToken }) + .send() + .expect(httpStatus.UNAUTHORIZED); + }); + + test('should return 401 if user is not found', async () => { + const expires = moment().add(config.jwt.verificationEmailExpirationMinutes, 'minutes'); + const verificationEmailToken = tokenService.generateToken(userOne._id, expires); + await tokenService.saveToken(verificationEmailToken, userOne._id, expires, tokenTypes.VERIFICATION_EMAIL); + + await request(app) + .post('/v1/auth/verify-email') + .query({ token: verificationEmailToken }) + .send() + .expect(httpStatus.UNAUTHORIZED); + }); + }); }); describe('Auth middleware', () => { diff --git a/tests/integration/user.test.js b/tests/integration/user.test.js index 9fe5fc52..99be1ca7 100644 --- a/tests/integration/user.test.js +++ b/tests/integration/user.test.js @@ -32,12 +32,18 @@ describe('User routes', () => { .expect(httpStatus.CREATED); expect(res.body).not.toHaveProperty('password'); - expect(res.body).toEqual({ id: expect.anything(), name: newUser.name, email: newUser.email, role: newUser.role }); + expect(res.body).toEqual({ + id: expect.anything(), + name: newUser.name, + email: newUser.email, + role: newUser.role, + isEmailVarified: false, + }); const dbUser = await User.findById(res.body.id); expect(dbUser).toBeDefined(); expect(dbUser.password).not.toBe(newUser.password); - expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: newUser.role }); + expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: newUser.role, isEmailVarified: false }); }); test('should be able to create an admin as well', async () => { @@ -157,6 +163,7 @@ describe('User routes', () => { name: userOne.name, email: userOne.email, role: userOne.role, + isEmailVarified: false, }); }); @@ -359,6 +366,7 @@ describe('User routes', () => { email: userOne.email, name: userOne.name, role: userOne.role, + isEmailVarified: false, }); }); @@ -491,6 +499,7 @@ describe('User routes', () => { name: updateBody.name, email: updateBody.email, role: 'user', + isEmailVarified: false, }); const dbUser = await User.findById(userOne._id);