Skip to content

Commit

Permalink
feat(authenication): implement password reset
Browse files Browse the repository at this point in the history
- user receives link to reset password via email
- user can reset password with the link received via email
- user can only use the link received via email once

[Delivers #159987402]
  • Loading branch information
tomiadebanjo committed Sep 10, 2018
1 parent b37f5d4 commit 182ca63
Show file tree
Hide file tree
Showing 11 changed files with 350 additions and 28 deletions.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
"description": "A Social platform for the creative at heart",
"main": "app.js",
"scripts": {
"pretest": "NODE_ENV=test sequelize db:migrate",
"pretest": "NODE_ENV=test sequelize db:migrate && NODE_ENV=test sequelize db:seed:all",
"test": "NODE_ENV=test nyc mocha --exit --require babel-core/register",
"posttest": "NODE_ENV=test sequelize db:migrate:undo:all",
"start": "babel-node server/app.js",
"start_dev": "nodemon --exec babel-node server/app.js",
"start_dev": "NODE_ENV=development nodemon --exec babel-node server/app.js",
"migrate": "sequelize db:migrate",
"unmigrate": "sequelize db:migrate:undo:all",
"coverage": "nyc report --reporter=text-lcov | coveralls"
Expand All @@ -20,6 +20,7 @@
"author": "Andela Simulations Programme",
"license": "MIT",
"dependencies": {
"@sendgrid/mail": "^6.3.1",
"bcrypt": "^3.0.0",
"body-parser": "^1.18.3",
"cors": "^2.8.4",
Expand Down
65 changes: 63 additions & 2 deletions server/controllers/userController.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import bcrypt from 'bcrypt';
import { config } from 'dotenv';
import models from '../models';
import generateToken from '../helpers/generateToken';
import helpers from '../helpers/helpers';
import mailer from '../helpers/mailer';
import emsg from '../helpers/eMsgs';

config();
const url = process.env.BASE_URL;

const { Users } = models;
const { msgForPasswordReset } = emsg;

const userController = {
/**
Expand All @@ -25,7 +34,7 @@ const userController = {
if (!created) {
return res.status(400).jsend.fail({ message: 'email already exist!' });
}
const token = generateToken(user.id, 7200);
const token = generateToken(7200, { id: user.id });

return res.status(201).jsend.success({
userId: user.id,
Expand Down Expand Up @@ -57,7 +66,7 @@ const userController = {
message: 'Invalid credentials supplied',
});
}
const token = generateToken(user.id, 7200);
const token = generateToken(7200, { id: user.id });

return res.status(200).jsend.success({
userId: user.id,
Expand All @@ -66,6 +75,58 @@ const userController = {
token
});
});
},
/**
* @description This is the method that generates the password reset email
* @param {object} req The request object
* @param {object} res The response object
* @returns {object} json response
*/
resetPassword: (req, res) => {
Users
.findOne({
where: { email: req.body.email }
})
.then((user) => {
if (!user) {
return res.status(401).jsend.error({
message: 'Invalid credentials supplied',
});
}
// generate token
const token = generateToken(600, { id: user.id, updatedAt: user.updatedAt });

mailer.sender({
to: user.email,
subject: 'Password reset',
message: msgForPasswordReset(user.username, url, token)
});
return res.status(200).jsend.success({
message: 'Password reset link has been sent to your email',
});
});
},
/**
* @description This is method that resets the users password
* @param {object} req The request object
* @param {object} res The response object
* @returns {object} json response
*/
reset: (req, res) => {
Users.findOne({
where: { id: req.currentUser.id }
}).then((user) => {
if (!helpers.compareDate(req.currentUser.updatedAt, user.updatedAt)) {
return res.status(401).jsend.error({ message: 'Verification link not valid' });
}
Users.update({
password: bcrypt.hashSync(req.body.password, 8)
}, {
where: { id: req.currentUser.id }
}).then(() => res.status(200).jsend.success({
message: 'Password reset successful',
}));
});
}
};

Expand Down
34 changes: 34 additions & 0 deletions server/helpers/eMsgs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export default {
verifiedMessage: `
<div>
<p>Hurry!!!!</p>
<p>You have successfully verified your account, and
we are supper exited to have you on our platform
Thanks!
</p>
</div>`,
msgOnRegistration: (username, url, token) => `
<div>
<h3>Hi ${username},</h3>
<p>Welcome to Author's Haven, we extend our gratitude to
to have you on our platform, But to activate your account please
click on this link <br />
<br /> <br />
<a href="${url}/api/verify/${token}"
style="border: 1px solid light-blue; background-color: blue; padding: 10px;
color: #fff; border-radius:10px; text-decoration: none" > Verify Account
<a>
</p>
</div>`,

msgForPasswordReset: (username, url, token) => `
<div>
<h3>Hi ${username},</h3>
<p>You requested for a Password reset on your account. You can use the following link to reset your password:
<br />
<br /> <br />
<a href="${url}/api/v1/users/api/auth/reset-password/${token}">${url}/api/v1/users/api/auth/reset-password/${token}</a>
</p>
<p>If you don’t use this link within 3 hours, it will expire. To get a new password reset link, visit <a href="${url}/api/v1/users/api/auth/reset-password/">${url}/api/v1/users/api/auth/reset-password/</a></p>
</div>`
};
6 changes: 3 additions & 3 deletions server/helpers/generateToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ const secret = process.env.SECRET;
const cryptr = new Cryptr(secret);
/**
* @description This function generates and encrypts JWT token
* @param {integer} id
* @param {integer} time
* @param {object} object
* @returns {string} encrypted JWT token
*/
const generateToken = (id, time) => {
const rawToken = jwt.sign({ id }, secret, { expiresIn: time });
const generateToken = (time, ...args) => {
const rawToken = jwt.sign(...args, secret, { expiresIn: time });
return cryptr.encrypt(rawToken);
};

Expand Down
12 changes: 12 additions & 0 deletions server/helpers/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const helpers = {
validPassword: (password) => {
let valid = true;
const invalidMessages = [];
// Password cant be empty

// Check that length is greater or equal to 8
if (password.trim().length < 8) {
valid = false;
Expand Down Expand Up @@ -41,6 +43,16 @@ const helpers = {
*/
validString: bar => bar.trim().length > 0,

/**
* @description This method checks the date the password
* was updated and compares it to the date in the token
* @param {string} currentDate
* @param {string} tokenDate
* @returns {boolean} A boolean representing if the token is still valid or not
*/
compareDate: (currentDate, tokenDate) => new Date(currentDate).getTime()
=== new Date(tokenDate).getTime(),

/**
* @description This method checks if an object contains a number of properties
* @param {object} obj The object to be searched
Expand Down
68 changes: 68 additions & 0 deletions server/helpers/mailer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { config } from 'dotenv';
import sgMail from '@sendgrid/mail';
import emsg from './eMsgs';

config();
const secret = process.env.SENDGRID_API_KEY;
sgMail.setApiKey(secret);
const url = process.env.BASE_URL;
const { msgOnRegistration } = emsg;

/**
* Mailer Event Emitter
* @exports
* @class Mailer
*/
export default class Mailer {
/**
* Email Transporter
* @method sender
* @memberof Mailer
* @param {string} email
* @param {string} subject
* @param {string} message
* @returns {nothing} returns nothing
*/
static sender({ to, subject, message }) {
const msg = {
to,
from: 'noreply@metis-ah',
subject,
html: message,
};
return sgMail.send(msg);
}

/**
* Sends Mail on user registration
* @method onUserRegistration
* @memberof Mailer
* @param {string} username
* @param {string} email user's email
* @param {string} token user's token
* @returns {function} sender
*/
static onUserRegistration(username, email, token) {
return Mailer.sender({
to: email,
subject: 'Verify user\'s account',
message: msgOnRegistration(username, url, token)
});
}

/**
* Email Sender helper function
* @method emailHelperfunc
* @memberof Mailer
* @param {*} args
* @returns {function} sender
*/
static emailHelperfunc(args) {
const { email, subject, message } = args;
return Mailer.sender({
to: email,
subject,
message
});
}
}
34 changes: 19 additions & 15 deletions server/middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,31 @@ const cryptr = new Cryptr(secret);
* @returns {object} req.currentUser
*/
const auth = (req, res, next) => {
const token = req.headers.authorization;
const token = req.headers.authorization || req.params.token;
if (!token) {
return res.status(401).jsend.error({
auth: false,
message: 'No token provided',
});
}
// decrypt token with cryptr
const unHashToken = cryptr.decrypt(token);

// verify token with jwt
jwt.verify(unHashToken, secret, (err, decoded) => {
if (err) {
return res.status(401).jsend.error({
auth: false,
message: 'Failed to authenticate token! Valid token required',
});
}
req.currentUser = decoded.id;
next();
});
try {
// decrypt token with cryptr
const unHashToken = cryptr.decrypt(token);
// verify token with jwt
jwt.verify(unHashToken, secret, (err, decoded) => {
if (err) {
return res.status(401).jsend.error({
auth: false,
message: 'Failed to authenticate token! Valid token required',
err,
});
}
req.currentUser = decoded;
next();
});
} catch (error) {
return res.status(401).jsend.error({ message: 'Invalid token' });
}
};

export default auth;
27 changes: 24 additions & 3 deletions server/middleware/usersValidations.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const usersValidations = {
.concat(helper.validPassword(req.body.password).invalidMessages);
}

// validate the firstName;
// validate the userName;
if (!helper.validString(req.body.username)) {
status = 'fail';
messages.push('username cannot be an empty string');
Expand All @@ -60,7 +60,7 @@ const usersValidations = {
*/
validateLogin: (req, res, next) => {
let status = 'success';
let messages = [];
const messages = [];

// Check the passed body for required properties
const { valid, invalidMessages } = helper
Expand All @@ -79,8 +79,29 @@ const usersValidations = {
messages.push('Invalid email provided');
}

if (status === 'fail') {
return res.status(400)
.jsend.fail({
messages
});
}
return next();
},
// Validate password on reset
validateNewPassword: (req, res, next) => {
let status = 'success';
let messages = [];

const { valid, invalidMessages } = helper
.checkProps(req.body, 'password');

if (!valid) {
return res.status(400)
.jsend.fail({
messages: invalidMessages
});
}

// Validate the password provided
if (!helper.validPassword(req.body.password).valid) {
status = 'fail';
messages = messages.concat(helper.validPassword(req.body.password).invalidMessages);
Expand Down
5 changes: 4 additions & 1 deletion server/routes/userRoutes.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import express from 'express';
import userController from '../controllers/userController';
import usersValidations from '../middleware/usersValidations';
import auth from '../middleware/auth';

const { validateSignUp, validateLogin } = usersValidations;
const { validateSignUp, validateLogin, validateNewPassword } = usersValidations;

const userRoutes = express.Router();

userRoutes.post('/auth/signup', validateSignUp, userController.signUp);
userRoutes.post('/auth/login', validateLogin, userController.login);
userRoutes.post('/api/auth/reset-password', userController.resetPassword);
userRoutes.put('/api/auth/reset-password/:token', auth, validateNewPassword, userController.reset);

export default userRoutes;
13 changes: 13 additions & 0 deletions server/seeders/20180910104809-generateUser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@

module.exports = {
up: queryInterface => queryInterface.bulkInsert('Users', [{
username: 'username',
email: 'username@gmail.com',
password: 'bouhsiudgsd',
isVerified: true,
createdAt: '2018-09-09',
updatedAt: '2018-09-09',
}]),

down: queryInterface => queryInterface.bulkDelete('Users', null, {})
};
Loading

0 comments on commit 182ca63

Please sign in to comment.