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 @@ + + +
+ +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 @@ - - -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:
+
-
+
|
+
{{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