Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feature]: Reset password #180

Merged
merged 7 commits into from
Jun 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions constant/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ module.exports = {

EMAIL_SUBJECTS: {
EMAIL_VERIFICATION: 'AltCamp Verification Code',
PASSWORD_RESET: 'AltCamp Password Reset Code',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think adding 'Altcamp' in front of this constant is unnecessary

},

GENDER: {
Expand Down
9 changes: 6 additions & 3 deletions model/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ const tokenSchema = new mongoose.Schema({
});

tokenSchema.pre('save', function (next) {
this.expiryTime = new Date(
this.createdAt.getTime() + 1 * 24 * 60 * 60 * 1000 // A day expiry
);
if (this.type === TOKEN_TYPE.EMAIL_VERIFICATION) {
this.expiryTime = new Date(this.createdAt.getTime() + 1 * 15 * 60 * 1000);
} else if (this.type === TOKEN_TYPE.PASSWORD_RESET) {
this.expiryTime = new Date(this.createdAt.getTime() + 1 * 10 * 60 * 1000);
}

next();
});

Expand Down
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"jsdom": "^22.0.0",
"jsonwebtoken": "^9.0.0",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"mongoose": "^6.8.4",
"morgan": "~1.9.1",
"nodemailer": "^6.9.3",
Expand Down
20 changes: 20 additions & 0 deletions src/accounts/accountsController.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,24 @@ async function updateAccount(req, res) {
new responseHandler(res, account, 200, RESPONSE_MESSAGE.SUCCESS);
}

const forgotPassword = async (req, res) => {
const { email } = req.body;
await accountsService.forgotPassword({ email });

new responseHandler(res, undefined, 200, RESPONSE_MESSAGE.SUCCESS);
};

const resetPassword = async (req, res) => {
const { token, password } = req.body;

const reset = await accountsService.resetPassword({
token,
newPassword: password,
});

new responseHandler(res, reset, 200, RESPONSE_MESSAGE.SUCCESS);
};

module.exports = {
deleteAccount,
getAccount,
Expand All @@ -98,4 +116,6 @@ module.exports = {
uploadProfilePicture,
deleteProfilePicture,
updatePassword,
forgotPassword,
resetPassword,
};
74 changes: 72 additions & 2 deletions src/accounts/accountsService.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,24 @@ const { omit } = require('lodash');
const cloudinary = require('cloudinary').v2;
const { cloudinary: cloudinaryConfig } = require('../../config');
const { Account, ...Model } = require('../../model');
const { NotFoundError, UnAuthorizedError } = require('../../utils/customError');
const { verifyPassword } = require('../../utils/helper');
const {
NotFoundError,
UnAuthorizedError,
BadRequestError,
} = require('../../utils/customError');
const {
verifyPassword,
getDifferenceInMinutes,
} = require('../../utils/helper');
const { validateCredentials } = require('../../utils/helper');
const { apiFeatures } = require('../common');
const {
TOKEN_TYPE,
EMAIL_TEMPLATES,
EMAIL_SUBJECTS,
} = require('../../constant');
const mailService = require('../mailer/mailerService');
const TokenService = require('../token/tokenService');
cloudinary.config({
cloud_name: cloudinaryConfig.name,
api_key: cloudinaryConfig.key,
Expand Down Expand Up @@ -36,6 +50,60 @@ async function updatePassword(userId, oldPassword, newPassword) {
return user;
}

const forgotPassword = async ({ email }) => {
const validUser = await Account.findOne({ email });

if (!validUser) throw new BadRequestError('User does not exist!');

const otpCode = Math.floor(Math.random() * 9000) + 1000;

const token = await TokenService.createToken({
token: otpCode,
type: TOKEN_TYPE.PASSWORD_RESET,
owner: validUser._id,
});

if (!token) throw new BadRequestError();

const tokenValidity = getDifferenceInMinutes(token);
const mailServicePayload = {
context: { name: validUser.firstName, token: otpCode, tokenValidity },
email: validUser.email,
templateName: EMAIL_TEMPLATES.PASSWORD_RESET,
subject: EMAIL_SUBJECTS.PASSWORD_RESET,
};

await mailService.sendMail(mailServicePayload);
};

const resetPassword = async ({ token, newPassword }) => {
const validToken = await TokenService.getToken({
type: TOKEN_TYPE.PASSWORD_RESET,
token: token,
});

if (!validToken) throw new BadRequestError('Token not found!');

if (!validToken.token === token) throw new BadRequestError('Invalid token');

if (validToken.expiryTime.getTime() <= Date.now()) {
TokenService.deleteToken(validToken._id);
throw new BadRequestError('Expired token');
}

let user = await Account.findById(validToken.owner).select('+password');

if (!user) throw new BadRequestError('User does not exist');

user.password = newPassword;

await user.save();
await validToken.delete();
user = omit(user.toObject(), ['password']);

return user;
};

async function getAccounts({ query }) {
const accountsQuery = Account.find({}).populate('owner');
const accounts = await new apiFeatures(accountsQuery, query)
Expand Down Expand Up @@ -145,4 +213,6 @@ module.exports = {
deleteProfilePicture,
deleteAccount,
updatePassword,
forgotPassword,
resetPassword,
};
34 changes: 34 additions & 0 deletions src/accounts/accountsValidator.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,45 @@ const deleteAccountValidator = Joi.object({
}),
});

const forgotPasswordValidator = Joi.object({
email: Joi.string().email().required().messages({
'string.email': 'Not a valid email address',
'string.empty': 'Email is required',
'any.required': 'Email is required',
}),
});

const resetPasswordValidator = Joi.object({
token: Joi.string()
.regex(/^\d{4}$/)
.required()
.messages({
'string.pattern.base': 'Token is invalid',
'any.required': 'Token is required',
}),
password: Joi.string()
.required()
.min(8)
.pattern(REGEX_PATTERNS.PASSWORD)
.messages({
'string.pattern.base':
'password must contain uppercase, lowercase, number and special character',
'string.min': 'Password must be at least 8 characters long',
'string.empty': 'Password is required',
'any.required': 'Password is required',
}),
retypePassword: Joi.string().required().valid(Joi.ref('password')).messages({
'any.only': 'Passwords must match',
}),
});

module.exports = {
deleteAccountValidator,
getAccountsValidator,
imageValidator,
passwordValidator,
profileBioValidator,
profileValidator,
forgotPasswordValidator,
resetPasswordValidator,
};
12 changes: 12 additions & 0 deletions src/accounts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const {
uploadProfilePicture,
deleteProfilePicture,
updatePassword,
forgotPassword,
resetPassword,
} = require('./accountsController');
const {
getAccountsValidator,
Expand All @@ -17,6 +19,8 @@ const {
deleteAccountValidator,
imageValidator,
passwordValidator,
forgotPasswordValidator,
resetPasswordValidator,
} = require('./accountsValidator');
const validator = require('../common/validator');

Expand Down Expand Up @@ -48,4 +52,12 @@ router
.route('/update-password')
.put(verifyUser, validatorMiddleware(passwordValidator), updatePassword);

router
.route('/forgot-password')
.post(validatorMiddleware(forgotPasswordValidator), forgotPassword);

router
.route('/reset-password')
.post(validatorMiddleware(resetPasswordValidator), resetPassword);

module.exports = router;
8 changes: 6 additions & 2 deletions src/auth/authController.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const authService = require('./authService');
const TokenService = require('../token/tokenService');
const accountService = require('../accounts/accountsService');
const mailService = require('../mailer/mailerService');
const { getDifferenceInMinutes } = require('../../utils/helper');

const registerAccount = async (req, res) => {
const payload = { ...req.body };
Expand Down Expand Up @@ -67,8 +68,9 @@ const verifyEmail = async (req, res) => {

if (!token) throw new BadRequestError();

const tokenValidity = getDifferenceInMinutes(token);
const mailServicePayload = {
context: { name: req.user.name, token },
context: { name: req.user.firstName, token: otpCode, tokenValidity },
email: req.user.email,
templateName: EMAIL_TEMPLATES.EMAIL_VERIFICATION,
subject: EMAIL_SUBJECTS.EMAIL_VERIFICATION,
Expand All @@ -95,8 +97,10 @@ const verifyEmailOtp = async (req, res) => {

if (!token.token === otp) throw new BadRequestError('Incorrect OTP!');

if (token.expiryTime <= Date.now())
if (token.expiryTime.getTime() <= Date.now()) {
TokenService.deleteToken(token._id);
throw new BadRequestError('Expired Token');
}

user.emailIsVerified = true;
await user.save();
Expand Down
8 changes: 6 additions & 2 deletions src/mailer/templates/emailVerification.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@
</head>
<body>
<p>Hello {{name}},</p>
<p>Enter the code below on altcamp to verify your email</p>
<p>Thank you for joining AltCamp. Use the following one-time password (OTP)
to verify your email address.</p>
<h3>{{token}}</h3>
<p>Token expires after 24 hours, Thank you</p>
<p>This OTP is valid for {{tokenValidity}}.</p>

<p>Cheers,</p>
<p><strong>AltCamp Team</strong></p>
</body>
</html>
17 changes: 17 additions & 0 deletions src/mailer/templates/passwordReset.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<html lang='en'>
<head>
<meta charset='UTF-8' />
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>PASSWORD RESET</title>
</head>
<body>
<p>Hello {{name}},</p>
<p>Use the following one-time password (OTP) to reset your password:</p>
<h3>{{token}}</h3>
<p>This OTP expires after {{tokenValidity}}.</p>

<p>Thank you,</p>
<p><strong>AltCamp Team</strong></p>
</body>
</html>
4 changes: 4 additions & 0 deletions src/token/tokenService.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,9 @@ class TokenService {
static async getToken(where) {
return await Token.findOne(where);
}

static async deleteToken(_id) {
return await Token.deleteOne(_id);
}
}
module.exports = TokenService;
2 changes: 1 addition & 1 deletion utils/errorHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function errorHandler(err, req, res, next) {
customError.error = 'Conflict';
}

if (err.responseCode === 535) {
if (err.responseCode) {
customError.statusCode = 500;
customError.msg = 'Unable to send e-mail!';
customError.error = 'Server error';
Expand Down
9 changes: 9 additions & 0 deletions utils/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const jwt = require('jsonwebtoken');
const { JSDOM } = require('jsdom');
const domPurify = createDomPurify(new JSDOM().window);
const slugify = require('slugify');
const moment = require('moment');

const createHashedToken = (token) => {
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
Expand Down Expand Up @@ -44,11 +45,19 @@ async function verifyPassword(plain, hashed) {
return await bcrypt.compare(plain, hashed);
}

function getDifferenceInMinutes({ createdAt, expiryTime }) {
const mExpiryTime = moment(expiryTime);
const mCreatedAt = moment(createdAt);
const diff = mExpiryTime.diff(mCreatedAt, 'minutes');
return `${diff} minute${diff > 1 ? 's' : ''}`;
}

module.exports = {
createHashedToken,
createToken,
generateSlug,
sanitiseHTML,
validateCredentials,
verifyPassword,
getDifferenceInMinutes,
};