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..9d8b886 100644 --- a/model/token.js +++ b/model/token.js @@ -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(); }); 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/src/accounts/accountsController.js b/src/accounts/accountsController.js index 5029a41..63121ef 100644 --- a/src/accounts/accountsController.js +++ b/src/accounts/accountsController.js @@ -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, @@ -98,4 +116,6 @@ module.exports = { uploadProfilePicture, deleteProfilePicture, updatePassword, + forgotPassword, + resetPassword, }; diff --git a/src/accounts/accountsService.js b/src/accounts/accountsService.js index cff74d7..cf1e1e2 100644 --- a/src/accounts/accountsService.js +++ b/src/accounts/accountsService.js @@ -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, @@ -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) @@ -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..c41f481 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 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, @@ -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/auth/authController.js b/src/auth/authController.js index 86ccd1f..d225270 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, @@ -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(); 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 new file mode 100644 index 0000000..e561580 --- /dev/null +++ b/src/mailer/templates/passwordReset.hbs @@ -0,0 +1,17 @@ + + + + + + PASSWORD RESET + + +

Hello {{name}},

+

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

+

{{token}}

+

This OTP expires after {{tokenValidity}}.

+ +

Thank you,

+

AltCamp Team

+ + \ No newline at end of file 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; 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'; 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, };