From da7a42c90831af8b99ba79bd7fc524875db59a5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n?= Date: Sat, 28 Mar 2026 23:05:49 +1100 Subject: [PATCH 1/3] feat: secure password verification and update API --- controller/userPasswordController.js | 188 ++++++++++++++++++++++++--- middleware/rateLimiter.js | 21 ++- routes/userpassword.js | 33 ++++- test/userPasswordController.test.js | 154 ++++++++++++++++++++++ 4 files changed, 371 insertions(+), 25 deletions(-) create mode 100644 test/userPasswordController.test.js diff --git a/controller/userPasswordController.js b/controller/userPasswordController.js index d9cbe4c8..1aebcb92 100644 --- a/controller/userPasswordController.js +++ b/controller/userPasswordController.js @@ -1,47 +1,195 @@ const bcrypt = require('bcryptjs'); let updateUser = require("../model/updateUserPassword.js"); let getUser = require("../model/getUserPassword.js"); +const authService = require("../services/authService"); + +const PASSWORD_RULES = [ + { + test: (password) => String(password || "").length >= 8, + code: "WEAK_PASSWORD", + error: "New password must be at least 8 characters long", + }, + { + test: (password) => /[A-Z]/.test(String(password || "")), + code: "WEAK_PASSWORD", + error: "New password must contain at least one uppercase letter", + }, + { + test: (password) => /[a-z]/.test(String(password || "")), + code: "WEAK_PASSWORD", + error: "New password must contain at least one lowercase letter", + }, + { + test: (password) => /[0-9]/.test(String(password || "")), + code: "WEAK_PASSWORD", + error: "New password must contain at least one number", + }, + { + test: (password) => /[!@#$%^&*()_\-+=[\]{};':"\\|,.<>/?]/.test(String(password || "")), + code: "WEAK_PASSWORD", + error: "New password must contain at least one special character", + }, +]; + +const jsonError = (res, status, error, code) => + res.status(status).json({ error, code }); + +const resolveAuthenticatedUserId = (req, res) => { + const tokenUserId = req.user?.userId; + const bodyUserId = req.body?.user_id; + + if (!tokenUserId) { + jsonError(res, 401, "Invalid or expired access token", "TOKEN_INVALID"); + return null; + } + + if (bodyUserId && String(bodyUserId) !== String(tokenUserId)) { + jsonError( + res, + 403, + "Authenticated user does not match requested account", + "UNAUTHORIZED_USER_CONTEXT" + ); + return null; + } + + return tokenUserId; +}; + +const findUserById = async (userId, res) => { + const user = await getUser(userId); + if (!user || user.length === 0) { + jsonError(res, 404, "User not found", "USER_NOT_FOUND"); + return null; + } + + return user[0]; +}; + +const validateStrongPassword = (password) => { + for (const rule of PASSWORD_RULES) { + if (!rule.test(password)) { + return { error: rule.error, code: rule.code }; + } + } + + return null; +}; + +const verifyCurrentPassword = async (req, res) => { + try { + const userId = resolveAuthenticatedUserId(req, res); + if (!userId) { + return; + } + + if (!req.body.password) { + return jsonError( + res, + 400, + "Current password is required", + "CURRENT_PASSWORD_REQUIRED" + ); + } + + const user = await findUserById(userId, res); + if (!user) { + return; + } + + const isPasswordValid = await bcrypt.compare(req.body.password, user.password); + if (!isPasswordValid) { + return jsonError( + res, + 401, + "Current password is incorrect", + "CURRENT_PASSWORD_INVALID" + ); + } + + return res.status(200).json({ + message: "Current password verified", + verified: true, + }); + } catch (error) { + console.error(error); + return jsonError(res, 500, "Internal server error", "INTERNAL_SERVER_ERROR"); + } +}; const updateUserPassword = async (req, res) => { try { - if (!req.body.user_id) { - return res.status(400).send({ message: "User ID is required" }); + const userId = resolveAuthenticatedUserId(req, res); + if (!userId) { + return; } if (!req.body.password) { - return res.status(400).send({ message: "Current password is required" }); + return jsonError( + res, + 400, + "Current password is required", + "CURRENT_PASSWORD_REQUIRED" + ); } if (!req.body.new_password) { - return res.status(400).send({ message: "New password is required" }); + return jsonError( + res, + 400, + "New password is required", + "NEW_PASSWORD_REQUIRED" + ); } - const user = await getUser(req.body.user_id); - if (!user || user.length === 0) { - return res - .status(401) - .json({ error: "Invalid user id" }); + if (req.body.password === req.body.new_password) { + return jsonError( + res, + 400, + "New password must be different from your current password", + "PASSWORD_REUSE" + ); } - const isPasswordValid = await bcrypt.compare(req.body.password, user[0].password); + const passwordStrengthError = validateStrongPassword(req.body.new_password); + if (passwordStrengthError) { + return jsonError( + res, + 400, + passwordStrengthError.error, + passwordStrengthError.code + ); + } + + const user = await findUserById(userId, res); + if (!user) { + return; + } + + const isPasswordValid = await bcrypt.compare(req.body.password, user.password); if (!isPasswordValid) { - return res - .status(401) - .json({ error: "Invalid password" }); + return jsonError( + res, + 401, + "Current password is incorrect", + "CURRENT_PASSWORD_INVALID" + ); } const hashedPassword = await bcrypt.hash(req.body.new_password, 10); - await updateUser( - req.body.user_id, - hashedPassword - ); + await updateUser(userId, hashedPassword); + await authService.logoutAll(userId); - res.status(200).json({ message: "Password updaded successfully" }); + return res.status(200).json({ + message: "Password updated successfully", + code: "PASSWORD_UPDATED", + require_reauthentication: true, + }); } catch (error) { console.error(error); - res.status(500).json({ message: "Internal server error" }); + return jsonError(res, 500, "Internal server error", "INTERNAL_SERVER_ERROR"); } }; -module.exports = { updateUserPassword }; \ No newline at end of file +module.exports = { verifyCurrentPassword, updateUserPassword }; diff --git a/middleware/rateLimiter.js b/middleware/rateLimiter.js index b1ce60dc..15cdd041 100644 --- a/middleware/rateLimiter.js +++ b/middleware/rateLimiter.js @@ -36,4 +36,23 @@ const formLimiter = rateLimit({ legacyHeaders: false, }); -module.exports = { loginLimiter, signupLimiter, formLimiter }; \ No newline at end of file +// For sensitive password verification / change flows +const passwordChangeLimiter = rateLimit({ + windowMs: 10 * 60 * 1000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => req.user?.userId || req.ip, + message: { + status: 429, + error: "Too many password verification attempts. Please try again later.", + code: "RATE_LIMITED", + }, +}); + +module.exports = { + loginLimiter, + signupLimiter, + formLimiter, + passwordChangeLimiter, +}; diff --git a/routes/userpassword.js b/routes/userpassword.js index f4209576..45eeab16 100644 --- a/routes/userpassword.js +++ b/routes/userpassword.js @@ -1,9 +1,34 @@ const express = require("express"); const router = express.Router(); const controller = require('../controller/userPasswordController.js'); +const { authenticateToken } = require('../middleware/authenticateToken'); +const { passwordChangeLimiter } = require('../middleware/rateLimiter'); -router.route('/').put(function(req,res) { - controller.updateUserPassword(req, res); -}); +router.post( + '/verify', + authenticateToken, + passwordChangeLimiter, + function(req, res) { + controller.verifyCurrentPassword(req, res); + } +); -module.exports = router; \ No newline at end of file +router.put( + '/update', + authenticateToken, + passwordChangeLimiter, + function(req, res) { + controller.updateUserPassword(req, res); + } +); + +router.put( + '/', + authenticateToken, + passwordChangeLimiter, + function(req, res) { + controller.updateUserPassword(req, res); + } +); + +module.exports = router; diff --git a/test/userPasswordController.test.js b/test/userPasswordController.test.js new file mode 100644 index 00000000..d4d1ff05 --- /dev/null +++ b/test/userPasswordController.test.js @@ -0,0 +1,154 @@ +const { expect } = require("chai"); +const proxyquire = require("proxyquire").noCallThru(); + +const createRes = () => { + const res = { + statusCode: 200, + body: null, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.body = payload; + return this; + }, + send(payload) { + this.body = payload; + return this; + }, + }; + + return res; +}; + +describe("userPasswordController", () => { + let bcrypt; + let getUser; + let updateUser; + let authService; + let controller; + + beforeEach(() => { + bcrypt = { + compare: jest.fn(), + hash: jest.fn(), + }; + getUser = jest.fn(); + updateUser = jest.fn(); + authService = { + logoutAll: jest.fn().mockResolvedValue({ success: true }), + }; + + controller = proxyquire("../controller/userPasswordController", { + bcryptjs: bcrypt, + "../model/getUserPassword.js": getUser, + "../model/updateUserPassword.js": updateUser, + "../services/authService": authService, + }); + }); + + it("verifies current password for the authenticated user", async () => { + const req = { + user: { userId: "user-123" }, + body: { user_id: "user-123", password: "CurrentPass123!" }, + }; + const res = createRes(); + + getUser.mockResolvedValue([{ password: "hashed-password" }]); + bcrypt.compare.mockResolvedValue(true); + + await controller.verifyCurrentPassword(req, res); + + expect(res.statusCode).to.equal(200); + expect(res.body).to.deep.equal({ + message: "Current password verified", + verified: true, + }); + }); + + it("rejects body-trusted identity mismatches", async () => { + const req = { + user: { userId: "user-123" }, + body: { user_id: "another-user", password: "CurrentPass123!" }, + }; + const res = createRes(); + + await controller.verifyCurrentPassword(req, res); + + expect(res.statusCode).to.equal(403); + expect(res.body.code).to.equal("UNAUTHORIZED_USER_CONTEXT"); + }); + + it("rejects invalid current password on verify", async () => { + const req = { + user: { userId: "user-123" }, + body: { password: "WrongPassword" }, + }; + const res = createRes(); + + getUser.mockResolvedValue([{ password: "hashed-password" }]); + bcrypt.compare.mockResolvedValue(false); + + await controller.verifyCurrentPassword(req, res); + + expect(res.statusCode).to.equal(401); + expect(res.body.code).to.equal("CURRENT_PASSWORD_INVALID"); + }); + + it("updates password, invalidates sessions, and requires reauthentication", async () => { + const req = { + user: { userId: "user-123" }, + body: { + password: "CurrentPass123!", + new_password: "NewPass123!", + }, + }; + const res = createRes(); + + getUser.mockResolvedValue([{ password: "hashed-password" }]); + bcrypt.compare.mockResolvedValue(true); + bcrypt.hash.mockResolvedValue("new-hash"); + updateUser.mockResolvedValue(undefined); + + await controller.updateUserPassword(req, res); + + expect(updateUser.mock.calls[0]).to.deep.equal(["user-123", "new-hash"]); + expect(authService.logoutAll.mock.calls[0]).to.deep.equal(["user-123"]); + expect(res.statusCode).to.equal(200); + expect(res.body.code).to.equal("PASSWORD_UPDATED"); + expect(res.body.require_reauthentication).to.equal(true); + }); + + it("rejects weak new passwords", async () => { + const req = { + user: { userId: "user-123" }, + body: { + password: "CurrentPass123!", + new_password: "weak", + }, + }; + const res = createRes(); + + await controller.updateUserPassword(req, res); + + expect(res.statusCode).to.equal(400); + expect(res.body.code).to.equal("WEAK_PASSWORD"); + }); + + it("rejects password reuse", async () => { + const req = { + user: { userId: "user-123" }, + body: { + password: "CurrentPass123!", + new_password: "CurrentPass123!", + }, + }; + const res = createRes(); + + await controller.updateUserPassword(req, res); + + expect(res.statusCode).to.equal(400); + expect(res.body.code).to.equal("PASSWORD_REUSE"); + }); +}); From 64c2b656352828c403ff14d0c7152da3f3d58c97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n?= Date: Mon, 30 Mar 2026 21:32:30 +1100 Subject: [PATCH 2/3] feat: add trusted-device MFA flow and confirm-password validation --- controller/authController.js | 50 ++++- controller/loginController.js | 123 ++++++++++-- controller/userPasswordController.js | 39 +++- index.yaml | 121 ++++++++++++ model/getUserPassword.js | 4 +- routes/auth.js | 3 +- routes/userpassword.js | 9 - services/authService.js | 171 ++++++++++++++++- test/loginController.trustedDevice.test.js | 206 +++++++++++++++++++++ test/userPasswordController.test.js | 83 +++++++-- 10 files changed, 759 insertions(+), 50 deletions(-) create mode 100644 test/loginController.trustedDevice.test.js diff --git a/controller/authController.js b/controller/authController.js index 63b4a173..dddb879f 100644 --- a/controller/authController.js +++ b/controller/authController.js @@ -1,6 +1,8 @@ const authService = require('../services/authService'); const { createClient } = require('@supabase/supabase-js'); +const TRUSTED_DEVICE_COOKIE = authService.trustedDeviceCookieName || 'trusted_device'; + const supabase = createClient( process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY @@ -128,7 +130,21 @@ exports.logoutAll = async (req, res) => { try { const userId = req.user.userId; - const result = await authService.logoutAll(userId); + const result = await authService.logoutAll(userId, { + reason: 'logout_all', + deviceInfo: { + ip: req.ip, + userAgent: req.get('User-Agent') || 'Unknown', + }, + }); + if (res.clearCookie) { + res.clearCookie(TRUSTED_DEVICE_COOKIE, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + }); + } res.json(result); @@ -141,6 +157,37 @@ exports.logoutAll = async (req, res) => { } }; +exports.revokeTrustedDevices = async (req, res) => { + try { + const userId = req.user.userId; + const result = await authService.revokeTrustedDevices(userId, 'manual', { + ip: req.ip, + userAgent: req.get('User-Agent') || 'Unknown', + }); + + if (res.clearCookie) { + res.clearCookie(TRUSTED_DEVICE_COOKIE, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + }); + } + + return res.json({ + success: true, + message: 'Trusted devices revoked successfully', + revokedCount: result.revokedCount, + }); + } catch (error) { + console.error('Revoke trusted devices error:', error); + return res.status(500).json({ + success: false, + error: error.message, + }); + } +}; + /** * Get Current User Profile */ @@ -251,4 +298,3 @@ exports.sendSMSByEmail = async (req, res) => { return res.status(500).json({ error: 'Internal server error' }); } }; - diff --git a/controller/loginController.js b/controller/loginController.js index 4c3f91c5..bf920902 100644 --- a/controller/loginController.js +++ b/controller/loginController.js @@ -3,11 +3,69 @@ const jwt = require("jsonwebtoken"); const logLoginEvent = require("../Monitor_&_Logging/loginLogger"); const getUserCredentials = require("../model/getUserCredentials.js"); const { addMfaToken, verifyMfaToken } = require("../model/addMfaToken.js"); +const authService = require("../services/authService"); const nodemailer = require("nodemailer"); const crypto = require("crypto"); const supabase = require("../dbConnection"); const { validationResult } = require("express-validator"); +const TRUSTED_DEVICE_COOKIE = authService.trustedDeviceCookieName || "trusted_device"; + +function readCookie(req, name) { + const cookieHeader = req.headers.cookie || ""; + const cookies = cookieHeader.split(";").map((part) => part.trim()).filter(Boolean); + + for (const cookie of cookies) { + const separatorIndex = cookie.indexOf("="); + if (separatorIndex === -1) continue; + const cookieName = cookie.slice(0, separatorIndex); + const cookieValue = cookie.slice(separatorIndex + 1); + if (cookieName === name) { + return decodeURIComponent(cookieValue); + } + } + + return null; +} + +function trustedDeviceCookieOptions() { + const maxAge = authService.trustedDeviceExpiry || 30 * 24 * 60 * 60 * 1000; + return { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge, + }; +} + +function setTrustedDeviceCookie(res, token) { + if (!res?.cookie) return; + res.cookie(TRUSTED_DEVICE_COOKIE, token, trustedDeviceCookieOptions()); +} + +function clearTrustedDeviceCookie(res) { + if (!res?.clearCookie) return; + res.clearCookie(TRUSTED_DEVICE_COOKIE, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }); +} + +function createAccessToken(user) { + return jwt.sign( + { + userId: user.user_id, + role: user.user_roles?.role_name || "unknown", + type: "access", + }, + process.env.JWT_TOKEN, + { expiresIn: "1h" } + ); +} + // Nodemailer transporter using Gmail no-reply account const transporter = nodemailer.createTransport({ service: "gmail", @@ -102,11 +160,39 @@ const login = async (req, res) => { // MFA handling if (user.mfa_enabled) { + const deviceInfo = { + ip: clientIp, + userAgent: req.headers["user-agent"] || "", + }; + const trustedDeviceToken = readCookie(req, TRUSTED_DEVICE_COOKIE); + const trustedDevice = await authService.validateTrustedDeviceToken( + user.user_id, + trustedDeviceToken, + deviceInfo + ); + + if (trustedDevice.valid) { + const token = createAccessToken(user); + setTrustedDeviceCookie(res, trustedDeviceToken); + return res.status(200).json({ + user, + token, + trusted_device: true, + mfa_skipped: true, + }); + } + + if (trustedDeviceToken && trustedDevice.reason !== "missing") { + clearTrustedDeviceCookie(res); + } + const token = crypto.randomInt(100000, 999999); await addMfaToken(user.user_id, token); await sendOtpEmail(user.email, token); return res.status(202).json({ - message: "An MFA Token has been sent to your email address" + message: "An MFA Token has been sent to your email address", + mfa_required: true, + trusted_device: false, }); } @@ -117,14 +203,7 @@ const login = async (req, res) => { userAgent: req.headers["user-agent"] }); - const token = jwt.sign( - { - userId: user.user_id, - role: user.user_roles?.role_name || "unknown" - }, - process.env.JWT_TOKEN, - { expiresIn: "1h" } - ); + const token = createAccessToken(user); return res.status(200).json({ user, token }); @@ -143,6 +222,7 @@ const loginMfa = async (req, res) => { const email = req.body.email?.trim().toLowerCase(); const password = req.body.password; const mfa_token = req.body.mfa_token; + const rememberDevice = req.body.remember_device !== false; if (!email || !password || !mfa_token) { return res.status(400).json({ error: "Email, password, and token are required" }); @@ -164,16 +244,21 @@ const loginMfa = async (req, res) => { return res.status(401).json({ error: "Token is invalid or has expired" }); } - const token = jwt.sign( - { - userId: user.user_id, - role: user.user_roles?.role_name || "unknown" - }, - process.env.JWT_TOKEN, - { expiresIn: "1h" } - ); + const token = createAccessToken(user); - return res.status(200).json({ user, token }); + if (rememberDevice) { + const trustedDevice = await authService.issueTrustedDeviceToken(user.user_id, { + ip: req.ip, + userAgent: req.get("User-Agent") || "Unknown", + }); + setTrustedDeviceCookie(res, trustedDevice.token); + } + + return res.status(200).json({ + user, + token, + trusted_device: rememberDevice, + }); } catch (err) { console.error("MFA login error:", err); @@ -226,4 +311,4 @@ async function sendFailedLoginAlert(email, ip) { } } -module.exports = { login, loginMfa }; \ No newline at end of file +module.exports = { login, loginMfa }; diff --git a/controller/userPasswordController.js b/controller/userPasswordController.js index 1aebcb92..d531b88c 100644 --- a/controller/userPasswordController.js +++ b/controller/userPasswordController.js @@ -3,6 +3,8 @@ let updateUser = require("../model/updateUserPassword.js"); let getUser = require("../model/getUserPassword.js"); const authService = require("../services/authService"); +const TRUSTED_DEVICE_COOKIE = authService.trustedDeviceCookieName || "trusted_device"; + const PASSWORD_RULES = [ { test: (password) => String(password || "").length >= 8, @@ -142,6 +144,24 @@ const updateUserPassword = async (req, res) => { ); } + if (!req.body.confirm_password) { + return jsonError( + res, + 400, + "Confirm password is required", + "CONFIRM_PASSWORD_REQUIRED" + ); + } + + if (req.body.new_password !== req.body.confirm_password) { + return jsonError( + res, + 400, + "Confirm password must match the new password", + "PASSWORD_MISMATCH" + ); + } + if (req.body.password === req.body.new_password) { return jsonError( res, @@ -179,12 +199,29 @@ const updateUserPassword = async (req, res) => { const hashedPassword = await bcrypt.hash(req.body.new_password, 10); await updateUser(userId, hashedPassword); - await authService.logoutAll(userId); + await authService.logoutAll(userId, { + reason: "password_change", + deviceInfo: { + ip: req.ip, + userAgent: req.get?.("User-Agent") || req.headers?.["user-agent"] || "Unknown", + }, + }); + if (res.clearCookie) { + res.clearCookie(TRUSTED_DEVICE_COOKIE, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }); + } + const requiresMfaLogin = Boolean(user.mfa_enabled); return res.status(200).json({ message: "Password updated successfully", code: "PASSWORD_UPDATED", require_reauthentication: true, + require_mfa: requiresMfaLogin, + reauthentication_flow: requiresMfaLogin ? "LOGIN_MFA" : "LOGIN", }); } catch (error) { console.error(error); diff --git a/index.yaml b/index.yaml index cb4f5f7f..1ef6e2f0 100644 --- a/index.yaml +++ b/index.yaml @@ -9,6 +9,8 @@ tags: description: System and security monitoring endpoints - name: LoginDashboard description: KPIs and trends from public.audit_logs + - name: Authentication + description: Login, MFA, and password management endpoints - name: Allergy description: Endpoints for allergy checks and warnings - name: Appointments @@ -3209,6 +3211,125 @@ paths: id,occurredAt,type,userId,sessionId,ipAddress,userAgent,source,metadataJson brute_4092...,2025-12-04T07:24:13.965+00:00,BRUTE_FORCE_DETECTED,,,,,public.brute_force_logs,"{""email"":""john@nutrihelp.com""}" + /userpassword/verify: + post: + tags: + - Authentication + summary: Verify the authenticated user's current password + description: Checks whether the supplied current password matches the authenticated account before continuing the password change flow. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + password: + type: string + example: CurrentPass123! + user_id: + type: string + description: Optional. If supplied, it must match the authenticated token userId. + example: user-123 + responses: + '200': + description: Current password verified successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Current password verified + verified: + type: boolean + example: true + '400': + description: Missing current password + '401': + description: Invalid access token or incorrect current password + '403': + description: Body user_id does not match the authenticated account + '404': + description: User not found + '429': + description: Too many password verification attempts + '500': + description: Internal server error + + /userpassword/update: + put: + tags: + - Authentication + summary: Update the authenticated user's password + description: Updates the password for the authenticated user, invalidates active sessions, and returns the reauthentication flow the client should follow next. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password, new_password, confirm_password] + properties: + password: + type: string + description: Current password + example: CurrentPass123! + new_password: + type: string + description: New password that satisfies the backend password policy + example: NewPass123! + confirm_password: + type: string + description: Must exactly match new_password + example: NewPass123! + user_id: + type: string + description: Optional. If supplied, it must match the authenticated token userId. + example: user-123 + responses: + '200': + description: Password updated successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Password updated successfully + code: + type: string + example: PASSWORD_UPDATED + require_reauthentication: + type: boolean + example: true + require_mfa: + type: boolean + example: true + reauthentication_flow: + type: string + enum: [LOGIN, LOGIN_MFA] + example: LOGIN_MFA + '400': + description: Missing current/new/confirm password, weak password, password mismatch, or password reuse + '401': + description: Invalid access token or incorrect current password + '403': + description: Body user_id does not match the authenticated account + '404': + description: User not found + '429': + description: Too many password verification or update attempts + '500': + description: Internal server error + components: securitySchemes: diff --git a/model/getUserPassword.js b/model/getUserPassword.js index 77abfb3f..afbbfcf5 100644 --- a/model/getUserPassword.js +++ b/model/getUserPassword.js @@ -4,7 +4,7 @@ async function getUserProfile(user_id) { try { let { data, error } = await supabase .from('users') - .select('user_id,password') + .select('user_id,email,password,mfa_enabled') .eq('user_id', user_id) return data } catch (error) { @@ -13,4 +13,4 @@ async function getUserProfile(user_id) { } -module.exports = getUserProfile; \ No newline at end of file +module.exports = getUserProfile; diff --git a/routes/auth.js b/routes/auth.js index 27a6cac9..67730ee4 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -11,6 +11,7 @@ router.post('/login', authController.login); router.post('/refresh', authController.refreshToken); router.post('/logout', authController.logout); router.post('/logout-all', authenticateToken, authController.logoutAll); +router.post('/trusted-devices/revoke', authenticateToken, authController.revokeTrustedDevices); router.get('/profile', authenticateToken, authController.getProfile); router.post('/log-login-attempt', authController.logLoginAttempt); @@ -34,4 +35,4 @@ router.get('/health', (req, res) => { }); }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/routes/userpassword.js b/routes/userpassword.js index 45eeab16..46b51a22 100644 --- a/routes/userpassword.js +++ b/routes/userpassword.js @@ -22,13 +22,4 @@ router.put( } ); -router.put( - '/', - authenticateToken, - passwordChangeLimiter, - function(req, res) { - controller.updateUserPassword(req, res); - } -); - module.exports = router; diff --git a/services/authService.js b/services/authService.js index 43de0c8c..3717873a 100644 --- a/services/authService.js +++ b/services/authService.js @@ -5,6 +5,7 @@ const { createClient } = require('@supabase/supabase-js'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcrypt'); const crypto = require('crypto'); +const logLoginEvent = require("../Monitor_&_Logging/loginLogger"); const supabaseAnon = createClient( process.env.SUPABASE_URL, @@ -20,6 +21,8 @@ class AuthService { constructor() { this.accessTokenExpiry = '15m'; this.refreshTokenExpiry = 7 * 24 * 60 * 60 * 1000; // 7 days + this.trustedDeviceExpiry = 30 * 24 * 60 * 60 * 1000; // 30 days + this.trustedDeviceCookieName = 'trusted_device'; } /* ========================= @@ -33,6 +36,27 @@ class AuthService { .slice(0, 16); } + hashDeviceFingerprint(deviceInfo = {}) { + return crypto + .createHash('sha256') + .update(String(deviceInfo.userAgent || 'unknown-device')) + .digest('hex'); + } + + async logSecurityEvent(userId, eventType, deviceInfo = {}, details = {}) { + try { + await logLoginEvent({ + userId, + eventType, + ip: deviceInfo.ip || null, + userAgent: deviceInfo.userAgent || null, + details, + }); + } catch { + // silent by design + } + } + /* ========================= Register ========================= */ @@ -281,19 +305,164 @@ class AuthService { /* ========================= Logout All ========================= */ - async logoutAll(userId) { + async logoutAll(userId, options = {}) { try { + const reason = options.reason || 'logout_all'; + const deviceInfo = options.deviceInfo || {}; + const { data: trustedDevices } = await supabaseService + .from('user_sessiontoken') + .select('id') + .eq('user_id', userId) + .eq('token_type', 'trusted_device') + .eq('is_active', true); + await supabaseService .from('user_sessiontoken') .update({ is_active: false }) .eq('user_id', userId); + if ((trustedDevices || []).length > 0) { + await this.logSecurityEvent(userId, 'TRUSTED_DEVICE_REVOKED', deviceInfo, { + reason, + revoked_count: trustedDevices.length, + }); + } + return { success: true, message: 'Logged out from all devices' }; } catch (error) { throw new Error(`Logout all failed: ${error.message}`); } } + async issueTrustedDeviceToken(userId, deviceInfo = {}) { + try { + const rawTrustedToken = crypto.randomBytes(32).toString('hex'); + const hashedTrustedToken = await bcrypt.hash(rawTrustedToken, 12); + const lookupHash = this.createLookupHash(rawTrustedToken); + const expiresAt = new Date(Date.now() + this.trustedDeviceExpiry); + const deviceFingerprint = this.hashDeviceFingerprint(deviceInfo); + + await supabaseService + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('user_id', userId) + .eq('token_type', 'trusted_device') + .eq('is_active', true) + .contains('device_info', { userAgentHash: deviceFingerprint }); + + const { error } = await supabaseService + .from('user_sessiontoken') + .insert({ + user_id: userId, + refresh_token: hashedTrustedToken, + refresh_token_lookup: lookupHash, + token_type: 'trusted_device', + device_info: { + trusted: true, + userAgentHash: deviceFingerprint, + }, + ip_address: deviceInfo.ip || null, + user_agent: deviceInfo.userAgent || null, + expires_at: expiresAt.toISOString(), + is_active: true, + }); + + if (error) throw error; + + await this.logSecurityEvent(userId, 'TRUSTED_DEVICE_CREATED', deviceInfo, { + expires_at: expiresAt.toISOString(), + }); + + return { + token: rawTrustedToken, + expiresAt, + }; + } catch (error) { + throw new Error(`Trusted device issue failed: ${error.message}`); + } + } + + async validateTrustedDeviceToken(userId, rawToken, deviceInfo = {}) { + try { + if (!userId || !rawToken) { + return { valid: false, reason: 'missing' }; + } + + const lookupHash = this.createLookupHash(rawToken); + const { data: sessions, error } = await supabaseService + .from('user_sessiontoken') + .select('id, refresh_token, expires_at, is_active, device_info') + .eq('user_id', userId) + .eq('token_type', 'trusted_device') + .eq('refresh_token_lookup', lookupHash) + .eq('is_active', true) + .limit(1); + + if (error || !sessions || sessions.length === 0) { + return { valid: false, reason: 'missing' }; + } + + const trustedDevice = sessions[0]; + const tokenMatches = await bcrypt.compare(rawToken, trustedDevice.refresh_token); + if (!tokenMatches) { + return { valid: false, reason: 'invalid' }; + } + + if (new Date(trustedDevice.expires_at) < new Date()) { + await supabaseService + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('id', trustedDevice.id); + return { valid: false, reason: 'expired' }; + } + + const expectedFingerprint = trustedDevice.device_info?.userAgentHash; + const currentFingerprint = this.hashDeviceFingerprint(deviceInfo); + if (expectedFingerprint && expectedFingerprint !== currentFingerprint) { + return { valid: false, reason: 'device_mismatch' }; + } + + await this.logSecurityEvent(userId, 'TRUSTED_DEVICE_USED', deviceInfo, { + trusted_device_id: trustedDevice.id, + }); + + return { valid: true, trustedDeviceId: trustedDevice.id }; + } catch (error) { + return { valid: false, reason: 'error', error }; + } + } + + async revokeTrustedDevices(userId, reason = 'manual', deviceInfo = {}) { + try { + const { data: trustedDevices } = await supabaseService + .from('user_sessiontoken') + .select('id') + .eq('user_id', userId) + .eq('token_type', 'trusted_device') + .eq('is_active', true); + + await supabaseService + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('user_id', userId) + .eq('token_type', 'trusted_device'); + + if ((trustedDevices || []).length > 0) { + await this.logSecurityEvent(userId, 'TRUSTED_DEVICE_REVOKED', deviceInfo, { + reason, + revoked_count: trustedDevices.length, + }); + } + + return { + success: true, + revokedCount: (trustedDevices || []).length, + }; + } catch (error) { + throw new Error(`Trusted device revoke failed: ${error.message}`); + } + } + /* ========================= Verify Access Token ========================= */ diff --git a/test/loginController.trustedDevice.test.js b/test/loginController.trustedDevice.test.js new file mode 100644 index 00000000..af893159 --- /dev/null +++ b/test/loginController.trustedDevice.test.js @@ -0,0 +1,206 @@ +const { expect } = require("chai"); +const sinon = require("sinon"); +const proxyquire = require("proxyquire").noCallThru(); + +function createRes() { + return { + statusCode: 200, + body: null, + cookies: [], + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.body = payload; + return this; + }, + cookie(name, value, options) { + this.cookies.push({ name, value, options }); + return this; + }, + clearCookie() { + return this; + }, + }; +} + +function createSupabaseStub() { + const insertStub = sinon.stub().resolves({}); + const deleteResolve = sinon.stub().resolves({}); + const gteStub = sinon.stub().resolves({ data: [], error: null }); + + return { + insertStub, + deleteResolve, + client: { + from() { + return { + select() { + return { + eq() { + return { + eq() { + return { + gte: gteStub, + }; + }, + }; + }, + }; + }, + insert: insertStub, + delete() { + return { + eq() { + return { + eq: deleteResolve, + }; + }, + }; + }, + }; + }, + }, + }; +} + +describe("loginController trusted device flow", () => { + let bcrypt; + let jwt; + let getUserCredentials; + let addMfaToken; + let verifyMfaToken; + let authService; + let validationResult; + let logLoginEvent; + let cryptoMock; + let sendMail; + let controller; + + beforeEach(() => { + bcrypt = { + compare: sinon.stub(), + }; + jwt = { + sign: sinon.stub().returns("jwt-token"), + }; + getUserCredentials = sinon.stub(); + addMfaToken = sinon.stub().resolves(); + verifyMfaToken = sinon.stub().resolves(true); + authService = { + trustedDeviceCookieName: "trusted_device", + trustedDeviceExpiry: 30 * 24 * 60 * 60 * 1000, + validateTrustedDeviceToken: sinon.stub(), + issueTrustedDeviceToken: sinon.stub(), + }; + validationResult = sinon.stub().returns({ + isEmpty: () => true, + array: () => [], + }); + logLoginEvent = sinon.stub().resolves(); + cryptoMock = { + randomInt: sinon.stub().returns(123456), + }; + sendMail = sinon.stub().resolves(); + + const supabaseStub = createSupabaseStub(); + + controller = proxyquire("../controller/loginController", { + bcryptjs: bcrypt, + jsonwebtoken: jwt, + "../model/getUserCredentials.js": getUserCredentials, + "../model/addMfaToken.js": { + addMfaToken, + verifyMfaToken, + }, + "../services/authService": authService, + "../Monitor_&_Logging/loginLogger": logLoginEvent, + crypto: cryptoMock, + "../dbConnection": supabaseStub.client, + "express-validator": { + validationResult, + }, + nodemailer: { + createTransport: () => ({ + sendMail, + }), + }, + }); + }); + + it("skips MFA when the trusted-device cookie is valid", async () => { + const req = { + body: { + email: "user@example.com", + password: "CurrentPass123!", + }, + headers: { + cookie: "trusted_device=trusted-token", + "user-agent": "test-agent", + }, + socket: { + remoteAddress: "::1", + }, + ip: "::1", + get(header) { + return this.headers[header.toLowerCase()]; + }, + }; + const res = createRes(); + + getUserCredentials.resolves({ + user_id: 100, + email: "user@example.com", + password: "hashed", + mfa_enabled: true, + user_roles: { role_name: "user" }, + }); + bcrypt.compare.resolves(true); + authService.validateTrustedDeviceToken.resolves({ valid: true }); + + await controller.login(req, res); + + expect(res.statusCode).to.equal(200); + expect(res.body.trusted_device).to.equal(true); + expect(res.body.mfa_skipped).to.equal(true); + expect(addMfaToken.called).to.equal(false); + expect(res.cookies[0].name).to.equal("trusted_device"); + }); + + it("issues a trusted-device cookie after successful MFA login", async () => { + const req = { + body: { + email: "user@example.com", + password: "CurrentPass123!", + mfa_token: "123456", + }, + headers: { + "user-agent": "test-agent", + }, + ip: "127.0.0.1", + get(header) { + return this.headers[header.toLowerCase()]; + }, + }; + const res = createRes(); + + getUserCredentials.resolves({ + user_id: 100, + email: "user@example.com", + password: "hashed", + user_roles: { role_name: "user" }, + }); + bcrypt.compare.resolves(true); + authService.issueTrustedDeviceToken.resolves({ + token: "trusted-device-token", + }); + + await controller.loginMfa(req, res); + + expect(res.statusCode).to.equal(200); + expect(res.body.trusted_device).to.equal(true); + expect(res.cookies[0].name).to.equal("trusted_device"); + expect(res.cookies[0].value).to.equal("trusted-device-token"); + }); +}); diff --git a/test/userPasswordController.test.js b/test/userPasswordController.test.js index d4d1ff05..e0c7b65e 100644 --- a/test/userPasswordController.test.js +++ b/test/userPasswordController.test.js @@ -1,10 +1,12 @@ const { expect } = require("chai"); const proxyquire = require("proxyquire").noCallThru(); +const sinon = require("sinon"); const createRes = () => { const res = { statusCode: 200, body: null, + cookiesCleared: [], status(code) { this.statusCode = code; return this; @@ -17,6 +19,10 @@ const createRes = () => { this.body = payload; return this; }, + clearCookie(name) { + this.cookiesCleared.push(name); + return this; + }, }; return res; @@ -31,13 +37,13 @@ describe("userPasswordController", () => { beforeEach(() => { bcrypt = { - compare: jest.fn(), - hash: jest.fn(), + compare: sinon.stub(), + hash: sinon.stub(), }; - getUser = jest.fn(); - updateUser = jest.fn(); + getUser = sinon.stub(); + updateUser = sinon.stub(); authService = { - logoutAll: jest.fn().mockResolvedValue({ success: true }), + logoutAll: sinon.stub().resolves({ success: true }), }; controller = proxyquire("../controller/userPasswordController", { @@ -55,8 +61,8 @@ describe("userPasswordController", () => { }; const res = createRes(); - getUser.mockResolvedValue([{ password: "hashed-password" }]); - bcrypt.compare.mockResolvedValue(true); + getUser.resolves([{ password: "hashed-password", mfa_enabled: true }]); + bcrypt.compare.resolves(true); await controller.verifyCurrentPassword(req, res); @@ -87,8 +93,8 @@ describe("userPasswordController", () => { }; const res = createRes(); - getUser.mockResolvedValue([{ password: "hashed-password" }]); - bcrypt.compare.mockResolvedValue(false); + getUser.resolves([{ password: "hashed-password", mfa_enabled: false }]); + bcrypt.compare.resolves(false); await controller.verifyCurrentPassword(req, res); @@ -102,22 +108,50 @@ describe("userPasswordController", () => { body: { password: "CurrentPass123!", new_password: "NewPass123!", + confirm_password: "NewPass123!", }, }; const res = createRes(); - getUser.mockResolvedValue([{ password: "hashed-password" }]); - bcrypt.compare.mockResolvedValue(true); - bcrypt.hash.mockResolvedValue("new-hash"); - updateUser.mockResolvedValue(undefined); + getUser.resolves([{ password: "hashed-password", mfa_enabled: true }]); + bcrypt.compare.resolves(true); + bcrypt.hash.resolves("new-hash"); + updateUser.resolves(undefined); await controller.updateUserPassword(req, res); - expect(updateUser.mock.calls[0]).to.deep.equal(["user-123", "new-hash"]); - expect(authService.logoutAll.mock.calls[0]).to.deep.equal(["user-123"]); + expect(updateUser.firstCall.args).to.deep.equal(["user-123", "new-hash"]); + expect(authService.logoutAll.firstCall.args[0]).to.equal("user-123"); + expect(authService.logoutAll.firstCall.args[1].reason).to.equal("password_change"); expect(res.statusCode).to.equal(200); expect(res.body.code).to.equal("PASSWORD_UPDATED"); expect(res.body.require_reauthentication).to.equal(true); + expect(res.body.require_mfa).to.equal(true); + expect(res.body.reauthentication_flow).to.equal("LOGIN_MFA"); + expect(res.cookiesCleared).to.include("trusted_device"); + }); + + it("returns standard login reauthentication when MFA is not enabled", async () => { + const req = { + user: { userId: "user-123" }, + body: { + password: "CurrentPass123!", + new_password: "NewPass123!", + confirm_password: "NewPass123!", + }, + }; + const res = createRes(); + + getUser.resolves([{ password: "hashed-password", mfa_enabled: false }]); + bcrypt.compare.resolves(true); + bcrypt.hash.resolves("new-hash"); + updateUser.resolves(undefined); + + await controller.updateUserPassword(req, res); + + expect(res.statusCode).to.equal(200); + expect(res.body.require_mfa).to.equal(false); + expect(res.body.reauthentication_flow).to.equal("LOGIN"); }); it("rejects weak new passwords", async () => { @@ -126,6 +160,7 @@ describe("userPasswordController", () => { body: { password: "CurrentPass123!", new_password: "weak", + confirm_password: "weak", }, }; const res = createRes(); @@ -142,6 +177,7 @@ describe("userPasswordController", () => { body: { password: "CurrentPass123!", new_password: "CurrentPass123!", + confirm_password: "CurrentPass123!", }, }; const res = createRes(); @@ -151,4 +187,21 @@ describe("userPasswordController", () => { expect(res.statusCode).to.equal(400); expect(res.body.code).to.equal("PASSWORD_REUSE"); }); + + it("rejects mismatched confirm password", async () => { + const req = { + user: { userId: "user-123" }, + body: { + password: "CurrentPass123!", + new_password: "NewPass123!", + confirm_password: "Mismatch123!", + }, + }; + const res = createRes(); + + await controller.updateUserPassword(req, res); + + expect(res.statusCode).to.equal(400); + expect(res.body.code).to.equal("PASSWORD_MISMATCH"); + }); }); From ca7c50082bf54c4a64b5114ee16d319448392af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n?= Date: Tue, 31 Mar 2026 18:51:52 +1100 Subject: [PATCH 3/3] feat: add backward-compatible auth routes for current frontend --- controller/loginController.js | 36 ++++- controller/passwordController.js | 214 +++++++++++++++++++++++++++ controller/userPasswordController.js | 25 +++- routes/index.js | 2 + routes/login.js | 5 + routes/password.js | 17 +++ routes/profile.js | 23 +++ routes/userpassword.js | 9 ++ 8 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 controller/passwordController.js create mode 100644 routes/password.js create mode 100644 routes/profile.js diff --git a/controller/loginController.js b/controller/loginController.js index bf920902..a258a201 100644 --- a/controller/loginController.js +++ b/controller/loginController.js @@ -266,6 +266,40 @@ const loginMfa = async (req, res) => { } }; +const resendMfa = async (req, res) => { + const email = req.body.email?.trim().toLowerCase(); + + if (!email) { + return res.status(400).json({ error: "Email is required" }); + } + + try { + const user = await getUserCredentials(email); + if (!user) { + return res.status(404).json({ + error: "Account not found. Please create an account first." + }); + } + + if (!user.mfa_enabled) { + return res.status(400).json({ + error: "MFA is not enabled for this account" + }); + } + + const token = crypto.randomInt(100000, 999999); + await addMfaToken(user.user_id, token); + await sendOtpEmail(user.email, token); + + return res.status(200).json({ + message: "A new MFA token has been sent to your email address" + }); + } catch (err) { + console.error("Resend MFA error:", err); + return res.status(500).json({ error: "Internal server error" }); + } +}; + // Send OTP email via Nodemailer async function sendOtpEmail(email, token) { try { @@ -311,4 +345,4 @@ async function sendFailedLoginAlert(email, ip) { } } -module.exports = { login, loginMfa }; +module.exports = { login, loginMfa, resendMfa }; diff --git a/controller/passwordController.js b/controller/passwordController.js new file mode 100644 index 00000000..474a710f --- /dev/null +++ b/controller/passwordController.js @@ -0,0 +1,214 @@ +const bcrypt = require("bcryptjs"); +const crypto = require("crypto"); +const nodemailer = require("nodemailer"); +const supabase = require("../dbConnection"); + +const RESET_CODE_TTL_MS = 10 * 60 * 1000; +const MAX_VERIFY_ATTEMPTS = 5; +const resetCodeStore = new Map(); + +const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: process.env.GMAIL_USER, + pass: process.env.GMAIL_APP_PASSWORD, + }, +}); + +const jsonError = (res, status, error, code) => + res.status(status).json({ error, message: error, code }); + +const normalizeEmail = (email) => String(email || "").trim().toLowerCase(); + +const generateCode = () => crypto.randomInt(100000, 999999).toString(); + +const getStoredReset = (email) => resetCodeStore.get(normalizeEmail(email)); + +const setStoredReset = (email, code) => { + resetCodeStore.set(normalizeEmail(email), { + code, + attempts: 0, + verified: false, + expireAt: Date.now() + RESET_CODE_TTL_MS, + }); +}; + +const clearStoredReset = (email) => { + resetCodeStore.delete(normalizeEmail(email)); +}; + +const sendResetCodeEmail = async (email, code) => { + await transporter.sendMail({ + from: `"NutriHelp Security" <${process.env.GMAIL_USER}>`, + to: email, + subject: "NutriHelp Password Reset Code", + text: + `Your password reset code is: ${code}\n\n` + + "This code expires in 10 minutes.\n\n" + + "If you did not request this, please ignore this email.\n\n" + + "– NutriHelp Security Team", + html: ` +

Your password reset code is:

+

${code}

+

This code expires in 10 minutes.

+

If you did not request this, please ignore this email.

+

– NutriHelp Security Team

+ `, + }); +}; + +const validateStrongPassword = (password) => { + const value = String(password || ""); + if (value.length < 8) return "Password must be at least 8 characters long"; + if (!/[A-Z]/.test(value)) return "Password must contain at least one uppercase letter"; + if (!/[a-z]/.test(value)) return "Password must contain at least one lowercase letter"; + if (!/[0-9]/.test(value)) return "Password must contain at least one number"; + if (!/[!@#$%^&*()_\-+=[\]{};':"\\|,.<>/?]/.test(value)) { + return "Password must contain at least one special character"; + } + return null; +}; + +const getUserByEmail = async (email) => { + const { data, error } = await supabase + .from("users") + .select("user_id, email, password") + .eq("email", email) + .maybeSingle(); + + if (error) throw error; + return data || null; +}; + +const requestReset = async (req, res) => { + const email = normalizeEmail(req.body?.email); + if (!email) { + return jsonError(res, 400, "Email is required", "EMAIL_REQUIRED"); + } + + try { + const user = await getUserByEmail(email); + if (user) { + const code = generateCode(); + setStoredReset(email, code); + await sendResetCodeEmail(email, code); + } + + return res.status(200).json({ + ok: true, + message: "If that email exists, a code was sent. Check your inbox.", + }); + } catch (error) { + console.error("Password reset request error:", error); + return jsonError(res, 500, "Internal server error", "INTERNAL_SERVER_ERROR"); + } +}; + +const verifyCode = async (req, res) => { + const email = normalizeEmail(req.body?.email); + const code = String(req.body?.code || "").trim(); + + if (!email || !code) { + return jsonError(res, 400, "Email and code are required", "EMAIL_CODE_REQUIRED"); + } + + const stored = getStoredReset(email); + if (!stored) { + return jsonError(res, 404, "No reset code requested or code expired", "RESET_CODE_NOT_FOUND"); + } + + if (Date.now() > stored.expireAt) { + clearStoredReset(email); + return jsonError(res, 410, "Code expired. Please request a new one.", "RESET_CODE_EXPIRED"); + } + + if (stored.code !== code) { + stored.attempts += 1; + if (stored.attempts >= MAX_VERIFY_ATTEMPTS) { + clearStoredReset(email); + return jsonError(res, 429, "Too many attempts. Request a new code.", "RESET_CODE_LOCKED"); + } + resetCodeStore.set(email, stored); + return jsonError(res, 401, "Invalid code", "RESET_CODE_INVALID"); + } + + stored.verified = true; + resetCodeStore.set(email, stored); + + return res.status(200).json({ + ok: true, + message: "Verification successful", + verified: true, + }); +}; + +const resetPassword = async (req, res) => { + const email = normalizeEmail(req.body?.email); + const code = String(req.body?.code || "").trim(); + const newPassword = req.body?.newPassword || req.body?.new_password; + + if (!email || !code || !newPassword) { + return jsonError( + res, + 400, + "Email, code, and new password are required", + "RESET_FIELDS_REQUIRED" + ); + } + + const strengthError = validateStrongPassword(newPassword); + if (strengthError) { + return jsonError(res, 400, strengthError, "WEAK_PASSWORD"); + } + + const stored = getStoredReset(email); + if (!stored) { + return jsonError(res, 404, "No reset code requested or code expired", "RESET_CODE_NOT_FOUND"); + } + + if (Date.now() > stored.expireAt) { + clearStoredReset(email); + return jsonError(res, 410, "Code expired. Please request a new one.", "RESET_CODE_EXPIRED"); + } + + if (stored.code !== code) { + return jsonError(res, 401, "Invalid code", "RESET_CODE_INVALID"); + } + + if (!stored.verified) { + return jsonError(res, 400, "Please verify your code before resetting password", "RESET_CODE_NOT_VERIFIED"); + } + + try { + const user = await getUserByEmail(email); + if (!user) { + clearStoredReset(email); + return jsonError(res, 404, "User not found", "USER_NOT_FOUND"); + } + + const hashedPassword = await bcrypt.hash(String(newPassword), 10); + const { error } = await supabase + .from("users") + .update({ password: hashedPassword }) + .eq("user_id", user.user_id); + + if (error) throw error; + + clearStoredReset(email); + + return res.status(200).json({ + ok: true, + message: "Password updated successfully", + code: "PASSWORD_UPDATED", + }); + } catch (error) { + console.error("Password reset error:", error); + return jsonError(res, 500, "Internal server error", "INTERNAL_SERVER_ERROR"); + } +}; + +module.exports = { + requestReset, + verifyCode, + resetPassword, +}; diff --git a/controller/userPasswordController.js b/controller/userPasswordController.js index d531b88c..a389fba4 100644 --- a/controller/userPasswordController.js +++ b/controller/userPasswordController.js @@ -144,7 +144,9 @@ const updateUserPassword = async (req, res) => { ); } - if (!req.body.confirm_password) { + const confirmPassword = req.body.confirm_password ?? req.body.new_password; + + if (!confirmPassword) { return jsonError( res, 400, @@ -153,7 +155,7 @@ const updateUserPassword = async (req, res) => { ); } - if (req.body.new_password !== req.body.confirm_password) { + if (req.body.new_password !== confirmPassword) { return jsonError( res, 400, @@ -229,4 +231,21 @@ const updateUserPassword = async (req, res) => { } }; -module.exports = { verifyCurrentPassword, updateUserPassword }; +const legacyPasswordHandler = async (req, res) => { + if ( + req.body?.password && + req.body?.new_password && + req.body.new_password === req.body.password && + !req.body?.confirm_password + ) { + return verifyCurrentPassword(req, res); + } + + if (req.body?.new_password && !req.body?.confirm_password) { + req.body.confirm_password = req.body.new_password; + } + + return updateUserPassword(req, res); +}; + +module.exports = { verifyCurrentPassword, updateUserPassword, legacyPasswordHandler }; diff --git a/routes/index.js b/routes/index.js index e2757b76..f2fd0feb 100644 --- a/routes/index.js +++ b/routes/index.js @@ -11,7 +11,9 @@ module.exports = app => { app.use("/api/imageClassification", require('./imageClassification')); app.use("/api/recipeImageClassification", require('./recipeImageClassification')); app.use("/api/userprofile", require('./userprofile')); // get profile, update profile, update by identifier (email or username) + app.use("/api/profile", require('./profile')); app.use("/api/userpassword", require('./userpassword')); + app.use("/api/password", require('./password')); app.use("/api/fooddata", require('./fooddata')); app.use("/api/user/preferences", require('./userPreferences')); app.use("/api/mealplan", require('./mealplan')); diff --git a/routes/login.js b/routes/login.js index 085b270a..faf96727 100644 --- a/routes/login.js +++ b/routes/login.js @@ -17,4 +17,9 @@ router.post('/mfa', loginLimiter, mfaloginValidator, validate, (req, res) => { controller.loginMfa(req, res); }); +// POST /login/resend-mfa +router.post('/resend-mfa', loginLimiter, (req, res) => { + controller.resendMfa(req, res); +}); + module.exports = router; diff --git a/routes/password.js b/routes/password.js new file mode 100644 index 00000000..77efabd7 --- /dev/null +++ b/routes/password.js @@ -0,0 +1,17 @@ +const express = require("express"); +const router = express.Router(); +const controller = require("../controller/passwordController"); + +router.post("/request-reset", (req, res) => { + controller.requestReset(req, res); +}); + +router.post("/verify-code", (req, res) => { + controller.verifyCode(req, res); +}); + +router.post("/reset", (req, res) => { + controller.resetPassword(req, res); +}); + +module.exports = router; diff --git a/routes/profile.js b/routes/profile.js new file mode 100644 index 00000000..cdf9db59 --- /dev/null +++ b/routes/profile.js @@ -0,0 +1,23 @@ +const express = require("express"); +const router = express.Router(); +const controller = require("../controller/userProfileController.js"); +const { authenticateToken } = require("../middleware/authenticateToken"); + +router.get("/", authenticateToken, (req, res) => { + req.params.userId = req.user.userId; + return controller.getUserProfile(req, res); +}); + +router.put("/", authenticateToken, (req, res) => { + req.body.user_id = req.body.user_id || req.user.userId; + if (String(req.user.userId) !== String(req.body.user_id) && req.user.role !== "admin") { + return res.status(403).json({ + success: false, + error: "Forbidden: You can only update your own profile", + }); + } + + return controller.updateUserProfile(req, res); +}); + +module.exports = router; diff --git a/routes/userpassword.js b/routes/userpassword.js index 46b51a22..c035ad42 100644 --- a/routes/userpassword.js +++ b/routes/userpassword.js @@ -22,4 +22,13 @@ router.put( } ); +router.put( + '/', + authenticateToken, + passwordChangeLimiter, + function(req, res) { + controller.legacyPasswordHandler(req, res); + } +); + module.exports = router;