Skip to content

Commit

Permalink
email verification added
Browse files Browse the repository at this point in the history
  • Loading branch information
mustafaMohd committed Feb 20, 2021
1 parent c96fae6 commit 9dae3f2
Show file tree
Hide file tree
Showing 15 changed files with 242 additions and 11 deletions.
17 changes: 17 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ module.exports = {
accessExpirationMinutes: envVars.JWT_ACCESS_EXPIRATION_MINUTES,
refreshExpirationDays: envVars.JWT_REFRESH_EXPIRATION_DAYS,
resetPasswordExpirationMinutes: 10,
verificationEmailExpirationMinutes: 10,
},
email: {
smtp: {
Expand Down
2 changes: 1 addition & 1 deletion src/config/roles.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions src/config/tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const tokenTypes = {
ACCESS: 'access',
REFRESH: 'refresh',
RESET_PASSWORD: 'resetPassword',
VERIFICATION_EMAIL: 'verificationEmail',
};

module.exports = {
Expand Down
13 changes: 13 additions & 0 deletions src/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,24 @@ 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,
logout,
refreshTokens,
forgotPassword,
resetPassword,
verificationEmail,
verifyEmail,
};
2 changes: 1 addition & 1 deletion src/models/token.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 4 additions & 0 deletions src/models/user.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ const userSchema = mongoose.Schema(
enum: roles,
default: 'user',
},
isEmailVarified: {
type: Boolean,
default: false,
},
},
{
timestamps: true,
Expand Down
8 changes: 8 additions & 0 deletions src/routes/v1/auth.route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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;

Expand Down
19 changes: 19 additions & 0 deletions src/services/auth.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
11 changes: 10 additions & 1 deletion src/services/email.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
12 changes: 11 additions & 1 deletion src/services/token.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
14 changes: 14 additions & 0 deletions src/validations/auth.validation.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* eslint-disable no-unused-vars */
/* eslint-disable prettier/prettier */
const Joi = require('joi');
const { password } = require('./custom.validation');

Expand Down Expand Up @@ -43,11 +45,23 @@ 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,
logout,
refreshTokens,
forgotPassword,
resetPassword,
verificationEmail,
verifyEmail,
};
133 changes: 129 additions & 4 deletions tests/integration/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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() },
Expand Down Expand Up @@ -91,6 +97,7 @@ describe('Auth routes', () => {
name: userOne.name,
email: userOne.email,
role: userOne.role,
isEmailVarified: false,
});

expect(res.body.tokens).toEqual({
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading

0 comments on commit 9dae3f2

Please sign in to comment.