Skip to content

Commit

Permalink
feature-[167190531]: Implement User Reset Password Via Email
Browse files Browse the repository at this point in the history
- create route to request and send password reset link to user email
- create route to reset password
- add valiadations for user inputs
- add model,unit and integration tests
- document feature with swagger

[Delivers #167190531]
  • Loading branch information
henryade authored and ayodejiAA committed Aug 9, 2019
1 parent cf541a6 commit d9e95db
Show file tree
Hide file tree
Showing 18 changed files with 679 additions and 36 deletions.
73 changes: 71 additions & 2 deletions server/controllers/Users.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import bcrypt from 'bcryptjs';
import models from '../database/models';
import verifyResetToken from '../helpers/verifyResetPasswordToken';

import {
serverResponse,
serverError,
generateToken,
expiryDate,
getUserAgent,
sendVerificationEmail
sendVerificationEmail,
sendResetPasswordEmail
} from '../helpers';

const { User, Session } = models;
const { User, Session, ResetPassword } = models;

/**
* @export
Expand Down Expand Up @@ -86,6 +89,72 @@ class Users {
return serverError(res);
}
}

/**
*
* @name passwordResetLink
* @static
* @param {object} req express object
* @param {object} res express object
* @memberof Users
* @returns {JSON} JSON object
*/
static async requestPasswordResetLink(req, res) {
try {
const { email } = req.body;
const user = await User.findByEmail(email);
if (!user) {
return serverResponse(res, 404, {
message: 'email address not found'
});
}
const { id } = user;
const token = generateToken({ id }, '1h');
await ResetPassword.create({ token, userId: id });
await Session.revokeAll(id);
sendResetPasswordEmail({ ...user, token });
return serverResponse(res, 200, {
message: 'password reset link sent'
});
} catch (error) {
return serverError(res);
}
}

/**
*@name resetPassword
*
* @static
* @param {object} req express object
* @param {object} res express object
* @memberof Users
* @returns {JSON} JSON object
*/
static async resetPassword(req, res) {
try {
const { password } = req.body;
const { token } = req.params;

const id = await verifyResetToken(token);
if (!id) {
return serverResponse(res, 401, {
error: 'link has expired or is invalid'
});
}
const resetToken = await ResetPassword.findOne({
where: { token }
});
if (!resetToken) {
return serverResponse(res, 401, { error: 'link has been used' });
}
const hashedPassword = await bcrypt.hash(password, 10);
await User.updatePasswordById(id, hashedPassword);
await ResetPassword.destroy({ where: { token } });
return serverResponse(res, 200, { message: 'password reset successful' });
} catch (error) {
return serverError(res);
}
}
}

export default Users;
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export default {
up: (queryInterface, Sequelize) => queryInterface.createTable('ResetPasswords', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
userId: {
allowNull: false,
type: Sequelize.INTEGER,
onDelete: 'CASCADE',
references: {
model: 'Users',
key: 'id'
}
},
token: {
type: Sequelize.STRING
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
}),
down: (queryInterface, Sequelize) => queryInterface.dropTable('ResetPasswords')
};
14 changes: 14 additions & 0 deletions server/database/models/resetpasswordtoken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export default (sequelize, DataTypes) => {
const ResetPassword = sequelize.define('ResetPassword', {
userId: {
type: DataTypes.INTEGER,
allowNull: false
},
token: {
type: DataTypes.STRING,
unique: true,
allowNull: false
}
});
return ResetPassword;
};
4 changes: 4 additions & 0 deletions server/database/models/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,9 @@ export default (sequelize, DataTypes) => {
onUpdate: 'CASCADE'
});
};

Session.revokeAll = async (userId) => {
await Session.update({ active: false }, { where: { userId } });
};
return Session;
};
12 changes: 12 additions & 0 deletions server/database/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ export default (sequelize, DataTypes) => {
if (user) return user;
return null;
};
User.updatePasswordById = async (id, newPassword) => {
const user = await User.update(
{ password: newPassword },
{ where: { id } }
);
return user[0];
};
User.associate = (models) => {
User.hasMany(models.Session, {
foreignKey: 'userId',
Expand All @@ -96,6 +103,11 @@ export default (sequelize, DataTypes) => {
onDelete: 'CASCADE',
as: 'AllFollowings'
});
User.hasMany(models.ResetPassword, {
foreignKey: 'userId',
as: 'ResetPassword',
onDelete: 'CASCADE'
});
};

return User;
Expand Down
98 changes: 98 additions & 0 deletions server/docs/authors-haven-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,93 @@ paths:
description: Success. Email verification was successful
500:
description: Internal server errorcomponents
/api/v1/users/resetpassword:
post:
summary: Request reset password link route
description: Allow existing users to request for password reset link
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- email
properties:
email:
type: string
example: JhayXXX@gmail.com
responses:
200:
description: password reset link sent
content:
application/json:
schema:
"$ref": "#/components/schemas/passwordResetLinkResponse"
404:
description: email address not found
content:
application/json:
schema:
"$ref": "#/components/schemas/errorResponse"
500:
description: Server Error
content:
application/json:
schema:
"$ref": "#/components/schemas/serverResponse"

/api/v1/users/resetpassword/{token}:
patch:
summary: Reset password route
description: Allow existing users to reset password
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- password
- confirmPassword
properties:
password:
type: string
example: JhayXXXgmail.com
confirmPassword:
type: string
example: JhayXXXgmail.com
parameters:
- in: path
name: token
required: true
schema:
type: string
description: The token sent to the user to reset password
responses:
200:
description: password reset successful
content:
application/json:
schema:
"$ref": "#/components/schemas/passwordResetResponse"
401:
description: reset link has expired, invalid or been used
content:
application/json:
schema:
"$ref": "#/components/schemas/errorResponse"
500:
description: Server Error
content:
application/json:
schema:
"$ref": "#/components/schemas/serverResponse"





components:
securitySchemes:
BearerAuth:
Expand Down Expand Up @@ -582,3 +669,14 @@ components:
email:
type: string
example: abiola@andela.com
passwordResetLinkResponse:
type: object
properties:
message:
type: string

passwordResetResponse:
type: object
properties:
message:
type: string
59 changes: 58 additions & 1 deletion server/helpers/emailTemplates.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,61 @@ your story preferences so we can serve you with a customized experience.</p>
return sendEmail(email, 'Verify Email', content);
};

export default sendVerificationEmail;
/**
* @name requestResetPasswordMail
* @param {Object} data object with user details
* @returns {Function} function that sends verification email to users
*/
const sendResetPasswordEmail = (data) => {
const { firstName, email, token } = data;
const content = `
<html>
<head>
<link href='https://fonts.googleapis.com/css?family=Montserrat' rel='stylesheet'>
<style>
body { font-family: Montserrat; font-style: normal; }
.logo { font-weight: normal;font-size: 30px;line-height: 37px;color:#505050; margin-left:20px } .container { margin: 0 7% } .forget__password { font-weight: 500; font-size: 22px; line-height: 30px; text-align:center } button { background: #505050; color: #ffffff; width: 205px; height: 40px; display: block; margin:auto; margin-top:40px; decoration: none } button:focus {outline:0;} .near__foot { margin-top:40px; width: 100%; height: 56px; background: #505050; } .link__text { padding-top:10px; margin: 30px 45px; color: #ffffff; font-size: 12px; } .link__text span { color: #D7B914 } .footer { display: flex; justify-content: space-around; margin-top: 20px; margin-bottom:40px }
</style>
</head>
<body>
<link href='https://fonts.googleapis.com/css?family=Montserrat' rel='stylesheet'>
<div class="container">
<div><h1 class="logo">Authors <span style="color: #D7B914">Haven</span></h1></div>
<div style="border: 0.5px solid rgba(0, 0, 0, 0.1);width:100%"></div>
<div style=" color: #505050"><p class="forget__password">Forgot Your Password?</p>
<div style="margin:0px 45px">
<div class="inner__body"> <p>Hi ${firstName},</p>
<p>
There was a request to change your password.</p>
<p style="line-height: 26px">
If you did not make this request, just ignore this email. Otherwise, please click the button below to change your password. <span style="color: #D7B914; font-weight: bold">This password reset is only valid for the next 60 minutes.</span></p></div>
<div style="align-items: center"><a style="margin-left:400px;text-decoration:none; background-color: #505050;
padding:15px; color:white"
href=${SERVER_URL}${BASE_URL}/users/resetpassword/${token}>
Reset Password
</a></div>
<p>
Thanks</p>
<p>
Authors Haven Team</p>
</div>
</div>
<div class="near__foot">
<p class="link__text">If you are having trouble with the above button, copy and paste the URL below into your browser.
<a style="color:#D7B914" >${SERVER_URL}${BASE_URL}/users/resetpassword/${token}</a></p>
</div>
<div class="footer">
</div>
</div>
</body>
</html>
`;
return sendEmail(email, 'Reset Password', content);
};

export default { sendVerificationEmail, sendResetPasswordEmail };
6 changes: 4 additions & 2 deletions server/helpers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import dateHelper from './dateHelper';
import getUserAgent from './getUserAgent';
import createSocialUsers from './createSocialUsers';
import getSocialUserData from './getSocialUserData';
import sendVerificationEmail from './emailTemplates';
import emailTemplates from './emailTemplates';

const { expiryDate } = dateHelper;
const { sendResetPasswordEmail, sendVerificationEmail } = emailTemplates;

export {
findUser,
Expand All @@ -23,5 +24,6 @@ export {
getUserAgent,
createSocialUsers,
getSocialUserData,
sendVerificationEmail
sendVerificationEmail,
sendResetPasswordEmail
};
19 changes: 19 additions & 0 deletions server/helpers/verifyResetPasswordToken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import jwt from 'jsonwebtoken';

/**
*
*
*
* @param {string} token jwt token
* @returns {integer} id of the user
*/
const verifyResetToken = async (token) => {
try {
const { id } = await jwt.verify(token, process.env.JWT_KEY);
return id;
} catch (error) {
return false;
}
};

export default verifyResetToken;
4 changes: 2 additions & 2 deletions server/middlewares/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { verifyToken, getSessionFromToken } from './verifyToken';
import validateUserSignup from './userValidation';
import validate from './userValidation';

const middlewares = { verifyToken, validateUserSignup, getSessionFromToken };
const middlewares = { verifyToken, validate, getSessionFromToken };

export default middlewares;
Loading

0 comments on commit d9e95db

Please sign in to comment.