From 6a4fe852e539d39203fd166c4a7248180133ee8b Mon Sep 17 00:00:00 2001 From: Isuru Bimsara Date: Fri, 21 Aug 2026 09:56:54 +0530 Subject: [PATCH] Implement password reset and change features with email notifications --- app/controllers/auth.controller.js | 212 ++++++++++++++++- app/models/user/user.model.js | 29 +-- app/routes/auth.routes.js | 2 + app/templates/emails/passwordChanged.html | 32 +++ app/templates/emails/passwordReset.html | 269 ++++++++++++++++++---- app/utils/passwordReset.utill.js | 191 ++++++++------- 6 files changed, 597 insertions(+), 138 deletions(-) create mode 100644 app/templates/emails/passwordChanged.html diff --git a/app/controllers/auth.controller.js b/app/controllers/auth.controller.js index 318604f..c1f2279 100644 --- a/app/controllers/auth.controller.js +++ b/app/controllers/auth.controller.js @@ -9,8 +9,12 @@ // app/controllers/auth.controller.js -const { checkPassword } = require("../utils/hashPassword.util"); +const { checkPassword, hashPassword } = require("../utils/hashPassword.util"); const { sendMail } = require("../utils/mail.util"); +const { + validatePassword, +} = require("../utils/validation/validatePassword.util"); +const { validateEmail } = require("../utils/validation/validateEmail.util"); const { generateOTP, validateOTP } = require("../utils/otp.util"); const { getCachedUser, clearUserCache } = require("../utils/cache.util"); const { generateToken } = require("../utils/jwt.util"); @@ -18,7 +22,15 @@ const { createRefreshSession, validateRefreshSession, deleteRefreshSession, + deleteAllUserSessions, } = require("../utils/refreshSession.util"); +const { + createPasswordReset, + verifyPasswordResetToken, + deletePasswordReset, + sendPasswordResetEmail, + sendPasswordChangedEmail +} = require("../utils/passwordReset.utill"); const db = require("../models"); const { log } = require("../utils/consoleLog.utill"); @@ -227,6 +239,204 @@ exports.refreshToken = async (req, res) => { } }; +exports.forgotPassword = async (req, res) => { + try { + let { email } = req.body; + + if (!email) { + return res.status(400).send({ + success: false, + message: "Email is required", + }); + } + + + email = email.trim().toLowerCase(); + + if (!validateEmail(email)) { + return res.status(400).send({ + success: false, + message: "Invalid email address", + }); + } + + const user = await User.findOne({ + where: { email }, + }); + + if (!user) { + return res.status(200).send({ + success: true, + message: + "If an account exists for this email, a password reset link has been sent.", + }); + } + + const resetToken = await createPasswordReset(user.id); + + await sendPasswordResetEmail(user.email, user.firstName, resetToken); + + return res.status(200).send({ + success: true, + message: + "If an account exists for this email, a password reset link has been sent.", + }); + } catch (error) { + console.error("FORGOT PASSWORD ERROR:", error); + + return res.status(500).send({ + success: false, + message: "Unable to process password reset request", + }); + } +}; + +exports.resetPassword = async (req, res) => { + + try { + + const { + token, + newPassword, + confirmPassword, + } = req.body; + + + if (!token ||!newPassword || !confirmPassword) { + return res.status(400).send({ + success: false, + message: + "Token, new password and confirm password are required", + }); + } + + if ( + newPassword !== + confirmPassword + ) { + return res.status(400).send({ + success: false, + message: + "Passwords do not match", + }); + } + + + if ( + !validatePassword(newPassword) + ) { + return res.status(400).send({ + success: false, + message: + "Password does not meet the required criteria", + }); + } + + const verification = + await verifyPasswordResetToken( + token + ); + + + if (!verification) { + return res.status(400).send({ + success: false, + message: + "Reset token is invalid or expired", + }); + } + + + const { + userId, + redisKey, + } = verification; + + const user = + await User.findByPk(userId); + + + if (!user) { + + await deletePasswordReset( + redisKey + ); + + return res.status(400).send({ + success: false, + message: + "Reset token is invalid or expired", + }); + } + + const samePassword = + await checkPassword( + newPassword, + user.password + ); + + + if (samePassword) { + return res.status(400).send({ + success: false, + message: + "New password must be different from the current password", + }); + } + + + const hashedPassword = + await hashPassword( + newPassword + ); + + + user.password = + hashedPassword; + + user.passwordChangedAt = + new Date(); + + + await user.save(); + + await sendPasswordChangedEmail( + user.email, + user.firstName + ); + + await deletePasswordReset( + redisKey + ); + + deleteAllUserSessions( + user.id + ); + + + return res.status(200).send({ + success: true, + message: + "Password reset successfully. Please login again.", + }); + + + } catch (error) { + + console.error( + "RESET PASSWORD ERROR:", + error + ); + + + return res.status(500).send({ + success: false, + message: + "Failed to reset password", + }); + } +}; + // Logout: Clear the JWT cookie exports.logout = (req, res) => { res.clearCookie("access_token", { diff --git a/app/models/user/user.model.js b/app/models/user/user.model.js index 2dc99c2..c147dd3 100644 --- a/app/models/user/user.model.js +++ b/app/models/user/user.model.js @@ -16,56 +16,60 @@ module.exports = (sequelize, DataTypes) => { id: { type: DataTypes.STRING, primaryKey: true, - collate: 'utf8mb4_general_ci' + collate: "utf8mb4_general_ci", }, firstName: { type: DataTypes.STRING, - allowNull: false + allowNull: false, }, lastName: { type: DataTypes.STRING, - allowNull: false + allowNull: false, }, email: { type: DataTypes.STRING, allowNull: false, - unique: true + unique: true, }, password: { type: DataTypes.STRING, - allowNull: false + allowNull: false, }, accountType: { type: DataTypes.ENUM("business_customer", "customer"), - defaultValue: "customer" + defaultValue: "customer", }, accountStatus: { type: DataTypes.ENUM( "PENDING_VERIFICATION", "ACTIVE", "SUSPENDED", - "DEACTIVATED" + "DEACTIVATED", ), allowNull: false, defaultValue: "PENDING_VERIFICATION", }, - emailVerifiedAt: { + emailVerifiedAt: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + passwordChangedAt: { type: DataTypes.DATE, allowNull: true, defaultValue: null, }, - }, { tableName: "users", - timestamps: true - } + timestamps: true, + }, ); User.associate = (db) => { User.hasMany(db.userPermission, { foreignKey: "user_id", - as: "userPermissions" + as: "userPermissions", }); User.hasOne(db.Profile, { foreignKey: "user_id", @@ -79,7 +83,6 @@ module.exports = (sequelize, DataTypes) => { foreignKey: "user_id", as: "businessCustomer", }); - }; return User; diff --git a/app/routes/auth.routes.js b/app/routes/auth.routes.js index 8e22a46..9818a5b 100644 --- a/app/routes/auth.routes.js +++ b/app/routes/auth.routes.js @@ -25,6 +25,8 @@ router.get("/me", authenticate, (req, res) => { router.post('/req-otp', authController.loginReq); router.post('/login', authController.login); router.post('/refresh', authController.refreshToken); +router.post('/forgot-password', authController.forgotPassword); +router.post('/reset-password', authController.resetPassword); router.post('/logout', authController.logout); module.exports = router; diff --git a/app/templates/emails/passwordChanged.html b/app/templates/emails/passwordChanged.html new file mode 100644 index 0000000..f7b4b0d --- /dev/null +++ b/app/templates/emails/passwordChanged.html @@ -0,0 +1,32 @@ + + + + + Your ZUMRI password was changed + + + + +

Hi {{customer_name}},

+ +

+ Your password was changed at {{changed_at}}. +

+ +

+ If this was not you, contact support immediately. +

+ +
+ +

+ Thank you,
+ ZUMRI Team +

+ +

+ Document Classification: Internal Use Only, Version: 1.0 +

+ + + \ No newline at end of file diff --git a/app/templates/emails/passwordReset.html b/app/templates/emails/passwordReset.html index 61c62e8..5884f70 100644 --- a/app/templates/emails/passwordReset.html +++ b/app/templates/emails/passwordReset.html @@ -1,53 +1,242 @@ - - - Document - + + + + + Reset Your ZUMRI Password - -

Dear {{firstName}},

-

Greetings from Oceanic Titan!

+ -

You have requested to reset your password for your Oceanic Titan account. Please click the link below to reset your password:

+ + + + +
-

Reset Password

+ -

If you did not request a password reset, please ignore this email. The above link will expire in {{expiryTime}}.

+ + + + -
-

Regards,
- Oceanic Titan Team -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ Hi {{firstName}}, +

+
+

+ Reset Your ZUMRI Password +

+
+

+ We received a request to reset the password + for your ZUMRI account. + Click the button below to create a new password. +

+
+ + + Reset Password + + +
+

+ If the button doesn't work, copy and paste + the following link into your browser: +

+ +

+ + {{resetLink}} + +

+
+

+ For security purposes, this password reset + link will expire in + {{expiryTime}}. +

+
+

+ If you did not request a password reset, + you can safely ignore this email. + Your password will remain unchanged. +

+
+

+ Best regards,
+ ZUMRI Team +

+
+

+ © {{currentYear}} ZUMRI. + All rights reserved. +

+
+ +
-

{{currentYear}} Oceanic Titan. This is an auto-generated email, please do not reply to this email.

\ No newline at end of file diff --git a/app/utils/passwordReset.utill.js b/app/utils/passwordReset.utill.js index fd6ea95..aca6e06 100644 --- a/app/utils/passwordReset.utill.js +++ b/app/utils/passwordReset.utill.js @@ -10,104 +10,127 @@ // app/utils/passwordReset.util.js const crypto = require("crypto"); +const redis = require("../config/redisClient"); +const { sendMail } = require("./mail.util"); -const createRedisConnection = require("../config/redis.config"); -const redis = createRedisConnection(); +const PASSWORD_RESET_TTL = + Number(process.env.PASSWORD_RESET_TTL_SECONDS) || 900; -const db = require("../models"); +const generatePasswordResetToken = () => { + return crypto.randomBytes(32).toString("hex"); +}; -const User = db.User; +const hashPasswordResetToken = (token) => { + return crypto.createHash("sha256").update(token).digest("hex"); +}; -const { log } = require("./consoleLog.utill"); -const { hashPassword } = require("./hashPassword.util"); +const createPasswordReset = async (userId) => { + // 1. Generate RAW token + const token = generatePasswordResetToken(); -const RESET_TOKEN_TTL = process.env.RESET_TOKEN_TTL || 10 * 60; // 10 minutes + // 2. Hash RAW token + const tokenHash = hashPasswordResetToken(token); -/** - * Generate password reset token - * - * @param {string} userId - * @returns {Promise} - */ -async function generateResetToken(userId) { - try { - const token = crypto.randomBytes(32).toString("hex"); + // 3. Create Redis key + const redisKey = `password-reset:${tokenHash}`; - const tokenHash = crypto - .createHash("sha256") - .update(token) - .digest("hex"); + // 4. Save user ID with 15 minute TTL + await redis.set(redisKey, userId, "EX", PASSWORD_RESET_TTL); - await redis.set( - `passwordReset:user:${userId}`, - tokenHash, - "EX", - RESET_TOKEN_TTL - ); - log(`Generated password reset token for user ${userId} with TTL of ${RESET_TOKEN_TTL} seconds, Generated token:`, token); - return token; - } catch (error) { - log("Password reset token generation failed", error); - throw new Error("Failed to generate password reset token"); + // Send only RAW token to user + return token; +}; + +const verifyPasswordResetToken = async (token) => { + if (!token || typeof token !== "string") { + return null; } -} -/** - * Reset password using token - * - * @param {string} userId - * @param {string} token - * @param {string} newPassword - */ -async function resetPassword(userId, token, newPassword) { - try { - const storedHash = await redis.get( - `passwordReset:user:${userId}` - ); + // Hash token received from user + const tokenHash = hashPasswordResetToken(token); - if (!storedHash) { - throw new Error("Invalid or expired reset token"); - } + // Create same Redis key + const redisKey = `password-reset:${tokenHash}`; - const tokenHash = crypto - .createHash("sha256") - .update(token) - .digest("hex"); + // Search Redis + const userId = await redis.get(redisKey); - const isValid = - crypto.timingSafeEqual( - Buffer.from(storedHash), - Buffer.from(tokenHash) - ); - - if (!isValid) { - throw new Error("Invalid or expired reset token"); - } - - const user = await User.findByPk(userId); - - if (!user) { - throw new Error("User not found"); - } - - user.password = await hashPassword(newPassword); - - await user.save(); - - await redis.del(`passwordReset:user:${userId}`); - - log( - `Password reset completed successfully for user ${userId}` - ); - - return true; - } catch (error) { - log("Password reset failed", error); - throw error; + if (!userId) { + return null; } -} + + return { + userId, + redisKey, + }; +}; + +const deletePasswordReset = async (redisKey) => { + await redis.del(redisKey); +}; + +const sendPasswordResetEmail = + async (email, firstName, resetToken) => { + + const resetLink = + `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`; + + + await sendMail({ + to: email, + + subject: + "Reset your ZUMRI password", + + templateName: + "passwordReset", + + templateVars: { + firstName: firstName, + resetLink: resetLink, + expiryTime: "15 minutes", + }, + + text: + `Hi ${firstName}, use this link within 15 minutes to reset your password: ${resetLink}`, + }); + }; + + const sendPasswordChangedEmail = async ( + email, + firstName +) => { + + const changedAt = + new Date().toLocaleString(); + + await sendMail({ + to: email, + + subject: + "Your ZUMRI password was changed", + + templateName: + "passwordChanged", + + templateVars: { + customer_name: + firstName, + + changed_at: + changedAt, + }, + + text: + `Hi ${firstName}, your password was changed at ${changedAt}. If this was not you, contact support immediately.`, + }); +}; module.exports = { - generateResetToken, - resetPassword + createPasswordReset, + verifyPasswordResetToken, + deletePasswordReset, + hashPasswordResetToken, + sendPasswordResetEmail, + sendPasswordChangedEmail, };