From d40febf0c4eee16f7493db90b56c3e7086533d5f Mon Sep 17 00:00:00 2001 From: Awosise Oluwaseun Date: Sun, 25 Jun 2023 10:26:30 +0100 Subject: [PATCH 1/7] feat: forgot and reset user password --- constant/index.js | 1 + model/token.js | 13 +++-- src/accounts/accountsController.js | 21 +++++++- src/accounts/accountsService.js | 72 +++++++++++++++++++++++++- src/accounts/accountsValidator.js | 34 ++++++++++++ src/accounts/index.js | 12 +++++ src/mailer/templates/passwordReset.hbs | 14 +++++ 7 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 src/mailer/templates/passwordReset.hbs diff --git a/constant/index.js b/constant/index.js index e082f79..9ef5848 100644 --- a/constant/index.js +++ b/constant/index.js @@ -29,6 +29,7 @@ module.exports = { EMAIL_SUBJECTS: { EMAIL_VERIFICATION: 'AltCamp Verification Code', + PASSWORD_RESET: 'AltCamp Password Reset Code', }, GENDER: { diff --git a/model/token.js b/model/token.js index b9adf8b..88ae8a0 100644 --- a/model/token.js +++ b/model/token.js @@ -23,9 +23,16 @@ 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 * 24 * 60 * 60 * 1000 // A day expiry + ); + } else if (this.type === TOKEN_TYPE.PASSWORD_RESET) { + this.expiryTime = new Date( + this.createdAt.getTime() + 1 * 10 * 60 * 1000 // 10 minutes expiry + ); + } + next(); }); diff --git a/src/accounts/accountsController.js b/src/accounts/accountsController.js index 5029a41..a23d970 100644 --- a/src/accounts/accountsController.js +++ b/src/accounts/accountsController.js @@ -1,7 +1,7 @@ const { RESPONSE_MESSAGE } = require('../../constant'); const responseHandler = require('../../utils/responseHandler'); const accountsService = require('./accountsService'); -const { NotFoundError } = require('../../utils/customError'); +const { NotFoundError, BadRequestError } = require('../../utils/customError'); async function uploadProfilePicture(req, res, next) { try { @@ -90,6 +90,23 @@ async function updateAccount(req, res) { new responseHandler(res, account, 200, RESPONSE_MESSAGE.SUCCESS); } +const forgotPassword = async (req, res) => { + const { email } = req.body; + const verifyUserAndSendOtp = await accountsService.forgotPassword({ email }); + + if (!verifyUserAndSendOtp) throw new BadRequestError(); + + new responseHandler(res, undefined, 200, RESPONSE_MESSAGE.SUCCESS); +}; + +const resetPassword = async (req, res) => { + const { token, newPassword } = req.body; + + const reset = await accountsService.resetPassword({ token, newPassword }); + + new responseHandler(res, reset, 200, RESPONSE_MESSAGE.SUCCESS); +}; + module.exports = { deleteAccount, getAccount, @@ -98,4 +115,6 @@ module.exports = { uploadProfilePicture, deleteProfilePicture, updatePassword, + forgotPassword, + resetPassword, }; diff --git a/src/accounts/accountsService.js b/src/accounts/accountsService.js index cff74d7..3d3d9e8 100644 --- a/src/accounts/accountsService.js +++ b/src/accounts/accountsService.js @@ -2,10 +2,21 @@ 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 { + NotFoundError, + UnAuthorizedError, + BadRequestError, +} = require('../../utils/customError'); const { verifyPassword } = 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, @@ -36,6 +47,63 @@ async function updatePassword(userId, oldPassword, newPassword) { return user; } +const forgotPassword = async ({ email }) => { + const validUser = await accountExists(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 mailServicePayload = { + context: { name: validUser.name, token }, + email: validUser.email, + templateName: EMAIL_TEMPLATES.PASSWORD_RESET, + subject: EMAIL_SUBJECTS.PASSWORD_RESET, + }; + + const sentMail = await mailService.sendMail(mailServicePayload); + + if ([/^[45]\d{2}$/].includes(sentMail.responseCode)) { + return false; + } else { + return true; + } +}; + +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 <= Date.now()) + 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) @@ -145,4 +213,6 @@ module.exports = { deleteProfilePicture, deleteAccount, updatePassword, + forgotPassword, + resetPassword, }; diff --git a/src/accounts/accountsValidator.js b/src/accounts/accountsValidator.js index 1f7038a..91de065 100644 --- a/src/accounts/accountsValidator.js +++ b/src/accounts/accountsValidator.js @@ -66,6 +66,38 @@ 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 must be a four-digit number', + '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': 'Password and retype password must match', + // }), +}); + module.exports = { deleteAccountValidator, getAccountsValidator, @@ -73,4 +105,6 @@ module.exports = { passwordValidator, profileBioValidator, profileValidator, + forgotPasswordValidator, + resetPasswordValidator, }; diff --git a/src/accounts/index.js b/src/accounts/index.js index ddb6b0e..9ff5295 100644 --- a/src/accounts/index.js +++ b/src/accounts/index.js @@ -9,6 +9,8 @@ const { uploadProfilePicture, deleteProfilePicture, updatePassword, + forgotPassword, + resetPassword, } = require('./accountsController'); const { getAccountsValidator, @@ -17,6 +19,8 @@ const { deleteAccountValidator, imageValidator, passwordValidator, + forgotPasswordValidator, + resetPasswordValidator, } = require('./accountsValidator'); const validator = require('../common/validator'); @@ -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; diff --git a/src/mailer/templates/passwordReset.hbs b/src/mailer/templates/passwordReset.hbs new file mode 100644 index 0000000..341316b --- /dev/null +++ b/src/mailer/templates/passwordReset.hbs @@ -0,0 +1,14 @@ + + + + + + PASSWORD RESET + + +

Hello {{name}},

+

Enter the code below on altcamp to reset your password

+

{{token}}

+

Token expires after 10 minutes, Thank you

+ + From 0571cc52688ef2fd48630bd7d6e96ea5622e679f Mon Sep 17 00:00:00 2001 From: Tobi Balogun <98078707+tobisupreme@users.noreply.github.com> Date: Mon, 26 Jun 2023 02:29:02 +0100 Subject: [PATCH 2/7] chore: set validation tokens expiry time --- model/token.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/model/token.js b/model/token.js index 88ae8a0..9d8b886 100644 --- a/model/token.js +++ b/model/token.js @@ -24,13 +24,9 @@ const tokenSchema = new mongoose.Schema({ tokenSchema.pre('save', function (next) { if (this.type === TOKEN_TYPE.EMAIL_VERIFICATION) { - this.expiryTime = new Date( - this.createdAt.getTime() + 1 * 24 * 60 * 60 * 1000 // A day expiry - ); + 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 // 10 minutes expiry - ); + this.expiryTime = new Date(this.createdAt.getTime() + 1 * 10 * 60 * 1000); } next(); From 5200f7de44ec9c3a310cbf7a8d0fce3031de5023 Mon Sep 17 00:00:00 2001 From: Tobi Balogun <98078707+tobisupreme@users.noreply.github.com> Date: Mon, 26 Jun 2023 02:31:58 +0100 Subject: [PATCH 3/7] chore: get difference in minutes --- package-lock.json | 14 ++++++++++++++ package.json | 1 + utils/helper.js | 9 +++++++++ 3 files changed, 24 insertions(+) diff --git a/package-lock.json b/package-lock.json index 6424e3a..1d99c0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,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", @@ -6874,6 +6875,14 @@ "node": ">=10" } }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "engines": { + "node": "*" + } + }, "node_modules/mongodb": { "version": "4.12.1", "license": "Apache-2.0", @@ -14461,6 +14470,11 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" }, + "moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" + }, "mongodb": { "version": "4.12.1", "requires": { diff --git a/package.json b/package.json index 928f282..553d3f9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/utils/helper.js b/utils/helper.js index df287cd..b48876f 100644 --- a/utils/helper.js +++ b/utils/helper.js @@ -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'); @@ -44,6 +45,13 @@ 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, @@ -51,4 +59,5 @@ module.exports = { sanitiseHTML, validateCredentials, verifyPassword, + getDifferenceInMinutes, }; From 6943224f20446f39b14a877792205d963865b0a6 Mon Sep 17 00:00:00 2001 From: Tobi Balogun <98078707+tobisupreme@users.noreply.github.com> Date: Mon, 26 Jun 2023 02:32:55 +0100 Subject: [PATCH 4/7] chore: modify email templates to dynamically include token validity --- src/accounts/accountsService.js | 16 +++++++--------- src/auth/authController.js | 4 +++- src/mailer/templates/emailVerification.hbs | 8 ++++++-- src/mailer/templates/passwordReset.hbs | 9 ++++++--- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/accounts/accountsService.js b/src/accounts/accountsService.js index 3d3d9e8..66ebe47 100644 --- a/src/accounts/accountsService.js +++ b/src/accounts/accountsService.js @@ -7,7 +7,10 @@ const { UnAuthorizedError, BadRequestError, } = require('../../utils/customError'); -const { verifyPassword } = require('../../utils/helper'); +const { + verifyPassword, + getDifferenceInMinutes, +} = require('../../utils/helper'); const { validateCredentials } = require('../../utils/helper'); const { apiFeatures } = require('../common'); const { @@ -62,20 +65,15 @@ const forgotPassword = async ({ email }) => { if (!token) throw new BadRequestError(); + const tokenValidity = getDifferenceInMinutes(token); const mailServicePayload = { - context: { name: validUser.name, token }, + context: { name: validUser.firstName, token: otpCode, tokenValidity }, email: validUser.email, templateName: EMAIL_TEMPLATES.PASSWORD_RESET, subject: EMAIL_SUBJECTS.PASSWORD_RESET, }; - const sentMail = await mailService.sendMail(mailServicePayload); - - if ([/^[45]\d{2}$/].includes(sentMail.responseCode)) { - return false; - } else { - return true; - } + await mailService.sendMail(mailServicePayload); }; const resetPassword = async ({ token, newPassword }) => { diff --git a/src/auth/authController.js b/src/auth/authController.js index 86ccd1f..00c584d 100644 --- a/src/auth/authController.js +++ b/src/auth/authController.js @@ -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 }; @@ -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, diff --git a/src/mailer/templates/emailVerification.hbs b/src/mailer/templates/emailVerification.hbs index 394bf99..4aa823f 100644 --- a/src/mailer/templates/emailVerification.hbs +++ b/src/mailer/templates/emailVerification.hbs @@ -7,8 +7,12 @@

Hello {{name}},

-

Enter the code below on altcamp to verify your email

+

Thank you for joining AltCamp. Use the following one-time password (OTP) + to verify your email address.

{{token}}

-

Token expires after 24 hours, Thank you

+

This OTP is valid for {{tokenValidity}}.

+ +

Cheers,

+

AltCamp Team

\ No newline at end of file diff --git a/src/mailer/templates/passwordReset.hbs b/src/mailer/templates/passwordReset.hbs index 341316b..e561580 100644 --- a/src/mailer/templates/passwordReset.hbs +++ b/src/mailer/templates/passwordReset.hbs @@ -7,8 +7,11 @@

Hello {{name}},

-

Enter the code below on altcamp to reset your password

+

Use the following one-time password (OTP) to reset your password:

{{token}}

-

Token expires after 10 minutes, Thank you

+

This OTP expires after {{tokenValidity}}.

+ +

Thank you,

+

AltCamp Team

- + \ No newline at end of file From cdee36f6b7435425be8745e029430d8689f62293 Mon Sep 17 00:00:00 2001 From: Tobi Balogun <98078707+tobisupreme@users.noreply.github.com> Date: Mon, 26 Jun 2023 02:37:11 +0100 Subject: [PATCH 5/7] fix: delete expired tokens --- src/accounts/accountsService.js | 4 +++- src/auth/authController.js | 4 +++- src/token/tokenService.js | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/accounts/accountsService.js b/src/accounts/accountsService.js index 66ebe47..e2a1180 100644 --- a/src/accounts/accountsService.js +++ b/src/accounts/accountsService.js @@ -86,8 +86,10 @@ const resetPassword = async ({ token, newPassword }) => { if (!validToken.token === token) throw new BadRequestError('Invalid token'); - if (validToken.expiryTime <= Date.now()) + if (validToken.expiryTime.getTime() <= Date.now()) { + TokenService.deleteToken(validToken._id); throw new BadRequestError('Expired token'); + } let user = await Account.findById(validToken.owner).select('+password'); diff --git a/src/auth/authController.js b/src/auth/authController.js index 00c584d..d225270 100644 --- a/src/auth/authController.js +++ b/src/auth/authController.js @@ -97,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(); diff --git a/src/token/tokenService.js b/src/token/tokenService.js index fa8412f..5511116 100644 --- a/src/token/tokenService.js +++ b/src/token/tokenService.js @@ -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; From 70b4b2daacab67e0c8408b1a713078b12a8a0952 Mon Sep 17 00:00:00 2001 From: Tobi Balogun <98078707+tobisupreme@users.noreply.github.com> Date: Mon, 26 Jun 2023 02:42:11 +0100 Subject: [PATCH 6/7] refactor: delegate responsibility to error handler --- src/accounts/accountsController.js | 13 +++++++------ utils/errorHandler.js | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/accounts/accountsController.js b/src/accounts/accountsController.js index a23d970..63121ef 100644 --- a/src/accounts/accountsController.js +++ b/src/accounts/accountsController.js @@ -1,7 +1,7 @@ const { RESPONSE_MESSAGE } = require('../../constant'); const responseHandler = require('../../utils/responseHandler'); const accountsService = require('./accountsService'); -const { NotFoundError, BadRequestError } = require('../../utils/customError'); +const { NotFoundError } = require('../../utils/customError'); async function uploadProfilePicture(req, res, next) { try { @@ -92,17 +92,18 @@ async function updateAccount(req, res) { const forgotPassword = async (req, res) => { const { email } = req.body; - const verifyUserAndSendOtp = await accountsService.forgotPassword({ email }); - - if (!verifyUserAndSendOtp) throw new BadRequestError(); + await accountsService.forgotPassword({ email }); new responseHandler(res, undefined, 200, RESPONSE_MESSAGE.SUCCESS); }; const resetPassword = async (req, res) => { - const { token, newPassword } = req.body; + const { token, password } = req.body; - const reset = await accountsService.resetPassword({ token, newPassword }); + const reset = await accountsService.resetPassword({ + token, + newPassword: password, + }); new responseHandler(res, reset, 200, RESPONSE_MESSAGE.SUCCESS); }; diff --git a/utils/errorHandler.js b/utils/errorHandler.js index e9acc62..b386245 100644 --- a/utils/errorHandler.js +++ b/utils/errorHandler.js @@ -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'; From 12163f30a59c3ca8c07d8e06937809f02964c82f Mon Sep 17 00:00:00 2001 From: Tobi Balogun <98078707+tobisupreme@users.noreply.github.com> Date: Mon, 26 Jun 2023 02:43:42 +0100 Subject: [PATCH 7/7] fix: add retypePassword key - Get user object from database --- src/accounts/accountsService.js | 2 +- src/accounts/accountsValidator.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/accounts/accountsService.js b/src/accounts/accountsService.js index e2a1180..cf1e1e2 100644 --- a/src/accounts/accountsService.js +++ b/src/accounts/accountsService.js @@ -51,7 +51,7 @@ async function updatePassword(userId, oldPassword, newPassword) { } const forgotPassword = async ({ email }) => { - const validUser = await accountExists(email); + const validUser = await Account.findOne({ email }); if (!validUser) throw new BadRequestError('User does not exist!'); diff --git a/src/accounts/accountsValidator.js b/src/accounts/accountsValidator.js index 91de065..c41f481 100644 --- a/src/accounts/accountsValidator.js +++ b/src/accounts/accountsValidator.js @@ -79,7 +79,7 @@ const resetPasswordValidator = Joi.object({ .regex(/^\d{4}$/) .required() .messages({ - 'string.pattern.base': 'Token must be a four-digit number', + 'string.pattern.base': 'Token is invalid', 'any.required': 'Token is required', }), password: Joi.string() @@ -93,9 +93,9 @@ const resetPasswordValidator = Joi.object({ 'string.empty': 'Password is required', 'any.required': 'Password is required', }), - // retypePassword: Joi.string().required().valid(Joi.ref('password')).messages({ - // 'any.only': 'Password and retype password must match', - // }), + retypePassword: Joi.string().required().valid(Joi.ref('password')).messages({ + 'any.only': 'Passwords must match', + }), }); module.exports = {