Implement password reset and change features with email notifications

This commit is contained in:
Isuru Bimsara
2026-08-21 09:56:54 +05:30
parent b2c5e198a1
commit 6a4fe852e5
6 changed files with 597 additions and 138 deletions
+211 -1
View File
@@ -9,8 +9,12 @@
// app/controllers/auth.controller.js // 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 { 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 { generateOTP, validateOTP } = require("../utils/otp.util");
const { getCachedUser, clearUserCache } = require("../utils/cache.util"); const { getCachedUser, clearUserCache } = require("../utils/cache.util");
const { generateToken } = require("../utils/jwt.util"); const { generateToken } = require("../utils/jwt.util");
@@ -18,7 +22,15 @@ const {
createRefreshSession, createRefreshSession,
validateRefreshSession, validateRefreshSession,
deleteRefreshSession, deleteRefreshSession,
deleteAllUserSessions,
} = require("../utils/refreshSession.util"); } = require("../utils/refreshSession.util");
const {
createPasswordReset,
verifyPasswordResetToken,
deletePasswordReset,
sendPasswordResetEmail,
sendPasswordChangedEmail
} = require("../utils/passwordReset.utill");
const db = require("../models"); const db = require("../models");
const { log } = require("../utils/consoleLog.utill"); 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 // Logout: Clear the JWT cookie
exports.logout = (req, res) => { exports.logout = (req, res) => {
res.clearCookie("access_token", { res.clearCookie("access_token", {
+16 -13
View File
@@ -16,56 +16,60 @@ module.exports = (sequelize, DataTypes) => {
id: { id: {
type: DataTypes.STRING, type: DataTypes.STRING,
primaryKey: true, primaryKey: true,
collate: 'utf8mb4_general_ci' collate: "utf8mb4_general_ci",
}, },
firstName: { firstName: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false allowNull: false,
}, },
lastName: { lastName: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false allowNull: false,
}, },
email: { email: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
unique: true unique: true,
}, },
password: { password: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false allowNull: false,
}, },
accountType: { accountType: {
type: DataTypes.ENUM("business_customer", "customer"), type: DataTypes.ENUM("business_customer", "customer"),
defaultValue: "customer" defaultValue: "customer",
}, },
accountStatus: { accountStatus: {
type: DataTypes.ENUM( type: DataTypes.ENUM(
"PENDING_VERIFICATION", "PENDING_VERIFICATION",
"ACTIVE", "ACTIVE",
"SUSPENDED", "SUSPENDED",
"DEACTIVATED" "DEACTIVATED",
), ),
allowNull: false, allowNull: false,
defaultValue: "PENDING_VERIFICATION", defaultValue: "PENDING_VERIFICATION",
}, },
emailVerifiedAt: { emailVerifiedAt: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: null,
},
passwordChangedAt: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: true, allowNull: true,
defaultValue: null, defaultValue: null,
}, },
}, },
{ {
tableName: "users", tableName: "users",
timestamps: true timestamps: true,
} },
); );
User.associate = (db) => { User.associate = (db) => {
User.hasMany(db.userPermission, { User.hasMany(db.userPermission, {
foreignKey: "user_id", foreignKey: "user_id",
as: "userPermissions" as: "userPermissions",
}); });
User.hasOne(db.Profile, { User.hasOne(db.Profile, {
foreignKey: "user_id", foreignKey: "user_id",
@@ -79,7 +83,6 @@ module.exports = (sequelize, DataTypes) => {
foreignKey: "user_id", foreignKey: "user_id",
as: "businessCustomer", as: "businessCustomer",
}); });
}; };
return User; return User;
+2
View File
@@ -25,6 +25,8 @@ router.get("/me", authenticate, (req, res) => {
router.post('/req-otp', authController.loginReq); router.post('/req-otp', authController.loginReq);
router.post('/login', authController.login); router.post('/login', authController.login);
router.post('/refresh', authController.refreshToken); router.post('/refresh', authController.refreshToken);
router.post('/forgot-password', authController.forgotPassword);
router.post('/reset-password', authController.resetPassword);
router.post('/logout', authController.logout); router.post('/logout', authController.logout);
module.exports = router; module.exports = router;
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Your ZUMRI password was changed</title>
</head>
<body style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #000; line-height: 1.6; margin: 0; padding: 20px;">
<p>Hi {{customer_name}},</p>
<p>
Your password was changed at {{changed_at}}.
</p>
<p>
If this was not you, contact support immediately.
</p>
<br>
<p>
Thank you,<br>
<b>ZUMRI Team</b>
</p>
<p style="font-size: 12px; color: #555;">
Document Classification: Internal Use Only, Version: 1.0
</p>
</body>
</html>
+229 -40
View File
@@ -1,53 +1,242 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title> <meta
<style> name="viewport"
body { content="width=device-width, initial-scale=1.0"
font-family: Arial, Helvetica, sans-serif; >
font-size: 14px;
color: #000; <title>Reset Your ZUMRI Password</title>
line-height: 1.6;
margin: 0;
padding: 20px;
}
table {
border-collapse: collapse;
width: 400px;
border: 1px solid #000;
}
th {
background-color: #053534;
color: #ffffff;
text-align: left;
}
td {
border: 1px solid #000;
}
a {
color: #1a73e8;
}
</style>
</head> </head>
<body>
<p>Dear {{firstName}},</p>
<p>Greetings from <b>Oceanic Titan</b>!</p> <body
style="
margin: 0;
padding: 0;
background-color: #f4f4f4;
font-family: Arial, Helvetica, sans-serif;
"
>
<p>You have requested to reset your password for your Oceanic Titan account. Please click the link below to reset your password:</p> <table
width="100%"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
background-color: #f4f4f4;
padding: 40px 0;
"
>
<tr>
<td align="center">
<p><a href="{{resetLink}}" target="_blank">Reset Password</a></p> <table
width="600"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
max-width: 600px;
width: 100%;
background-color: #ffffff;
padding: 40px;
border-radius: 8px;
"
>
<p>If you did not request a password reset, please ignore this email. The above link will expire in <b>{{expiryTime}}</b>.</p> <!-- Greeting -->
<tr>
<td>
<p
style="
font-size: 18px;
color: #333333;
margin-bottom: 30px;
"
>
Hi {{firstName}},
</p>
</td>
</tr>
<br>
<p>Regards,<br> <!-- Title -->
<b>Oceanic Titan Team</b> <tr>
</p> <td>
<h2
style="
color: #222222;
margin-bottom: 25px;
"
>
Reset Your ZUMRI Password
</h2>
</td>
</tr>
<!-- Description -->
<tr>
<td>
<p
style="
font-size: 16px;
line-height: 1.6;
color: #555555;
"
>
We received a request to reset the password
for your ZUMRI account.
Click the button below to create a new password.
</p>
</td>
</tr>
<!-- Reset Button -->
<tr>
<td
align="center"
style="padding: 30px 0;"
>
<a
href="{{resetLink}}"
target="_blank"
style="
display: inline-block;
padding: 14px 28px;
background-color: #222222;
color: #ffffff;
text-decoration: none;
font-size: 16px;
font-weight: bold;
border-radius: 6px;
"
>
Reset Password
</a>
</td>
</tr>
<!-- Direct Link -->
<tr>
<td>
<p
style="
font-size: 14px;
line-height: 1.6;
color: #777777;
"
>
If the button doesn't work, copy and paste
the following link into your browser:
</p>
<p
style="
font-size: 13px;
line-height: 1.5;
word-break: break-all;
"
>
<a
href="{{resetLink}}"
target="_blank"
style="color: #0066cc;"
>
{{resetLink}}
</a>
</p>
</td>
</tr>
<!-- Expiry Information -->
<tr>
<td>
<p
style="
font-size: 14px;
line-height: 1.6;
color: #777777;
margin-top: 30px;
"
>
For security purposes, this password reset
link will expire in
<strong>{{expiryTime}}</strong>.
</p>
</td>
</tr>
<!-- Security Message -->
<tr>
<td>
<p
style="
font-size: 14px;
line-height: 1.6;
color: #777777;
"
>
If you did not request a password reset,
you can safely ignore this email.
Your password will remain unchanged.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td>
<p
style="
font-size: 16px;
color: #333333;
margin-top: 30px;
"
>
Best regards,<br>
<strong>ZUMRI Team</strong>
</p>
</td>
</tr>
<!-- Copyright -->
<tr>
<td
align="center"
style="
border-top: 1px solid #eeeeee;
padding-top: 20px;
"
>
<p
style="
font-size: 12px;
color: #999999;
"
>
&copy; {{currentYear}} ZUMRI.
All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="font-size: 12px; color: #555;">{{currentYear}} Oceanic Titan. This is an auto-generated email, please do not reply to this email.</p>
</body> </body>
</html> </html>
+107 -84
View File
@@ -10,104 +10,127 @@
// app/utils/passwordReset.util.js // app/utils/passwordReset.util.js
const crypto = require("crypto"); const crypto = require("crypto");
const redis = require("../config/redisClient");
const { sendMail } = require("./mail.util");
const createRedisConnection = require("../config/redis.config"); const PASSWORD_RESET_TTL =
const redis = createRedisConnection(); 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 createPasswordReset = async (userId) => {
const { hashPassword } = require("./hashPassword.util"); // 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);
/** // 3. Create Redis key
* Generate password reset token const redisKey = `password-reset:${tokenHash}`;
*
* @param {string} userId
* @returns {Promise<string>}
*/
async function generateResetToken(userId) {
try {
const token = crypto.randomBytes(32).toString("hex");
const tokenHash = crypto // 4. Save user ID with 15 minute TTL
.createHash("sha256") await redis.set(redisKey, userId, "EX", PASSWORD_RESET_TTL);
.update(token)
.digest("hex");
await redis.set( // Send only RAW token to user
`passwordReset:user:${userId}`, return token;
tokenHash, };
"EX",
RESET_TOKEN_TTL const verifyPasswordResetToken = async (token) => {
); if (!token || typeof token !== "string") {
log(`Generated password reset token for user ${userId} with TTL of ${RESET_TOKEN_TTL} seconds, Generated token:`, token); return null;
return token;
} catch (error) {
log("Password reset token generation failed", error);
throw new Error("Failed to generate password reset token");
} }
}
/** // Hash token received from user
* Reset password using token const tokenHash = hashPasswordResetToken(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}`
);
if (!storedHash) { // Create same Redis key
throw new Error("Invalid or expired reset token"); const redisKey = `password-reset:${tokenHash}`;
}
const tokenHash = crypto // Search Redis
.createHash("sha256") const userId = await redis.get(redisKey);
.update(token)
.digest("hex");
const isValid = if (!userId) {
crypto.timingSafeEqual( return null;
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;
} }
}
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 = { module.exports = {
generateResetToken, createPasswordReset,
resetPassword verifyPasswordResetToken,
deletePasswordReset,
hashPasswordResetToken,
sendPasswordResetEmail,
sendPasswordChangedEmail,
}; };