diff --git a/controller/authController.js b/controller/authController.js index 2b018d7e..b1a96aa9 100644 --- a/controller/authController.js +++ b/controller/authController.js @@ -1,6 +1,8 @@ const authService = require('../services/authService'); const { isServiceError } = require('../services/serviceError'); +const TRUSTED_DEVICE_COOKIE = authService.trustedDeviceCookieName || 'trusted_device'; + function getDeviceInfo(req) { return { ip: req.ip, @@ -8,6 +10,19 @@ function getDeviceInfo(req) { }; } +function clearTrustedDeviceCookie(res) { + if (!res?.clearCookie) { + return; + } + + res.clearCookie(TRUSTED_DEVICE_COOKIE, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/' + }); +} + function handleServiceError(res, error, fallbackStatus, fallbackLogLabel) { if (isServiceError(error)) { return res.status(error.statusCode).json({ @@ -72,13 +87,37 @@ exports.logout = async (req, res) => { exports.logoutAll = async (req, res) => { try { - const result = await authService.logoutAll(req.user.userId); + const result = await authService.logoutAll(req.user.userId, { + reason: 'logout_all', + deviceInfo: getDeviceInfo(req) + }); + + clearTrustedDeviceCookie(res); return res.json(result); } catch (error) { return handleServiceError(res, error, 500, 'Logout all error:'); } }; +exports.revokeTrustedDevices = async (req, res) => { + try { + const result = await authService.revokeTrustedDevices( + req.user.userId, + 'manual', + getDeviceInfo(req) + ); + + clearTrustedDeviceCookie(res); + return res.json({ + success: true, + message: 'Trusted devices revoked successfully', + revokedCount: result.revokedCount + }); + } catch (error) { + return handleServiceError(res, error, 500, 'Revoke trusted devices error:'); + } +}; + exports.getProfile = async (req, res) => { try { const result = await authService.getProfile(req.user.userId); @@ -104,7 +143,7 @@ exports.logLoginAttempt = async (req, res) => { return res.status(error.statusCode).json({ error: error.message }); } - console.error('❌ Failed to insert login log:', error); + console.error('Failed to insert login log:', error); return res.status(500).json({ error: 'Failed to log login attempt' }); } }; @@ -118,7 +157,7 @@ exports.sendSMSByEmail = async (req, res) => { return res.status(error.statusCode).json({ error: error.message }); } - console.error('❌ Error sending SMS:', error); + console.error('Error sending SMS:', error); return res.status(500).json({ error: 'Internal server error' }); } }; diff --git a/controller/loginController.js b/controller/loginController.js index a39041af..2812c9dd 100644 --- a/controller/loginController.js +++ b/controller/loginController.js @@ -1,52 +1,343 @@ -const loginService = require('../services/loginService'); -const { isServiceError } = require('../services/serviceError'); +const bcrypt = require('bcryptjs'); +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'); -function getRequestContext(req) { +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 { - ip: req.headers['x-forwarded-for'] || req.socket?.remoteAddress || req.ip, - userAgent: req.get('User-Agent') || req.headers['user-agent'] || '' + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge }; } -function handleError(res, error, fallbackMessage) { - if (isServiceError(error)) { - if (error.details?.warningOnly) { - return res.status(error.statusCode).json({ warning: error.message }); - } +function setTrustedDeviceCookie(res, token) { + if (!res?.cookie) { + return; + } + + res.cookie(TRUSTED_DEVICE_COOKIE, token, trustedDeviceCookieOptions()); +} - return res.status(error.statusCode).json({ error: error.message }); +function clearTrustedDeviceCookie(res) { + if (!res?.clearCookie) { + return; } - console.error(fallbackMessage, error); - return res.status(500).json({ error: 'Internal server error' }); + 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' } + ); } -async function login(req, res) { +const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: process.env.GMAIL_USER, + pass: process.env.GMAIL_APP_PASSWORD + } +}); + +const login = async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const email = req.body.email?.trim().toLowerCase(); + const password = req.body.password; + + let clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress || req.ip; + clientIp = clientIp === '::1' ? '127.0.0.1' : clientIp; + + if (!email || !password) { + return res.status(400).json({ error: 'Email and password are required' }); + } + + const tenMinutesAgoISO = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + try { - const result = await loginService.login({ - email: req.body.email, - password: req.body.password, - ...getRequestContext(req) + const { data: failuresByEmail } = await supabase + .from('brute_force_logs') + .select('id') + .eq('email', email) + .eq('success', false) + .gte('created_at', tenMinutesAgoISO); + + const failureCount = failuresByEmail?.length || 0; + + if (failureCount >= 10) { + return res.status(429).json({ + error: 'Too many failed login attempts. Please try again after 10 minutes.' + }); + } + + const user = await getUserCredentials(email); + if (!user) { + await supabase.from('brute_force_logs').insert([{ + email, + ip_address: clientIp, + success: false, + created_at: new Date().toISOString() + }]); + await sendFailedLoginAlert(email, clientIp); + return res.status(404).json({ + error: 'Account not found. Please create an account first.' + }); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + if (!isPasswordValid) { + await supabase.from('brute_force_logs').insert([{ + email, + ip_address: clientIp, + success: false, + created_at: new Date().toISOString() + }]); + + if (failureCount === 4) { + return res.status(429).json({ + warning: 'You have one attempt left before your account is temporarily locked.' + }); + } + + await sendFailedLoginAlert(email, clientIp); + return res.status(401).json({ error: 'Invalid password' }); + } + + await supabase.from('brute_force_logs').insert([{ + email, + success: true, + created_at: new Date().toISOString() + }]); + + await supabase.from('brute_force_logs').delete() + .eq('email', email) + .eq('success', false); + + 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 mfaToken = crypto.randomInt(100000, 999999); + await addMfaToken(user.user_id, mfaToken); + await sendOtpEmail(user.email, mfaToken); + return res.status(202).json({ + message: 'An MFA Token has been sent to your email address', + mfa_required: true, + trusted_device: false + }); + } + + await logLoginEvent({ + userId: user.user_id, + eventType: 'LOGIN_SUCCESS', + ip: clientIp, + userAgent: req.headers['user-agent'] }); - return res.status(result.statusCode).json(result.body); - } catch (error) { - return handleError(res, error, 'Login error:'); + const token = createAccessToken(user); + return res.status(200).json({ user, token }); + } catch (err) { + console.error('Login error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}; + +const loginMfa = async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const email = req.body.email?.trim().toLowerCase(); + const password = req.body.password; + const mfaToken = req.body.mfa_token; + const rememberDevice = req.body.remember_device !== false; + + if (!email || !password || !mfaToken) { + return res.status(400).json({ error: 'Email, password, and token are required' }); + } + + try { + const user = await getUserCredentials(email); + if (!user) { + return res.status(401).json({ error: 'Invalid email or password' }); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + if (!isPasswordValid) { + return res.status(401).json({ error: 'Invalid email or password' }); + } + + const tokenValid = await verifyMfaToken(user.user_id, mfaToken); + if (!tokenValid) { + return res.status(401).json({ error: 'Token is invalid or has expired' }); + } + + const token = createAccessToken(user); + + 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); + return res.status(500).json({ error: 'Internal server error' }); + } +}; + +const resendMfa = async (req, res) => { + const email = req.body.email?.trim().toLowerCase(); + + if (!email) { + return res.status(400).json({ error: 'Email is required' }); } -} -async function loginMfa(req, res) { try { - const result = await loginService.loginMfa({ - email: req.body.email, - password: req.body.password, - mfaToken: req.body.mfa_token + 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' }); + } +}; - return res.status(result.statusCode).json(result.body); +async function sendOtpEmail(email, token) { + try { + await transporter.sendMail({ + from: `"NutriHelp Security" <${process.env.GMAIL_USER}>`, + to: email, + subject: 'NutriHelp Login Token', + text: `Your one-time login token is: ${token}\n\nThis token expires in 10 minutes.\n\nIf you did not request this, please ignore this email.\n\n- NutriHelp Security Team`, + html: ` +

Your one-time login token is:

+

${token}

+

This token expires in 10 minutes.

+

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

+
+

- NutriHelp Security Team

+ ` + }); + console.log('OTP email sent successfully to', email); + } catch (err) { + console.error('Error sending OTP email:', err.message); + } +} + +async function sendFailedLoginAlert(email, ip) { + try { + await transporter.sendMail({ + from: `"NutriHelp Security" <${process.env.GMAIL_USER}>`, + to: email, + subject: 'Failed Login Attempt on NutriHelp', + text: `Hi,\n\nSomeone tried to log in to NutriHelp using your email address from IP: ${ip}.\n\nIf this was not you, please ignore this message. If you are concerned, consider resetting your password or contacting support.\n\n- NutriHelp Security Team`, + html: ` +

Hi,

+

Someone tried to log in to NutriHelp using your email address from IP: ${ip}.

+

If this was not you, please ignore this message. If you are concerned, consider resetting your password or contacting support.

+
+

- NutriHelp Security Team

+ ` + }); } catch (error) { - return handleError(res, error, 'MFA login error:'); + console.error('Failed to send alert email:', error.message); } } -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 d9cbe4c8..a389fba4 100644 --- a/controller/userPasswordController.js +++ b/controller/userPasswordController.js @@ -1,47 +1,251 @@ const bcrypt = require('bcryptjs'); 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, + 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 confirmPassword = req.body.confirm_password ?? req.body.new_password; + + if (!confirmPassword) { + return jsonError( + res, + 400, + "Confirm password is required", + "CONFIRM_PASSWORD_REQUIRED" + ); + } + + if (req.body.new_password !== confirmPassword) { + return jsonError( + res, + 400, + "Confirm password must match the new password", + "PASSWORD_MISMATCH" + ); + } + + if (req.body.password === req.body.new_password) { + return jsonError( + res, + 400, + "New password must be different from your current password", + "PASSWORD_REUSE" + ); } - const user = await getUser(req.body.user_id); - if (!user || user.length === 0) { - return res - .status(401) - .json({ error: "Invalid user id" }); + const passwordStrengthError = validateStrongPassword(req.body.new_password); + if (passwordStrengthError) { + return jsonError( + res, + 400, + passwordStrengthError.error, + passwordStrengthError.code + ); } - const isPasswordValid = await bcrypt.compare(req.body.password, user[0].password); + 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, { + 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); - res.status(200).json({ message: "Password updaded successfully" }); + 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); - 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 +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/index.yaml b/index.yaml index d6f6691d..08ed899c 100644 --- a/index.yaml +++ b/index.yaml @@ -17,6 +17,8 @@ tags: description: Home Service API - name: Auth description: Authentication and user session endpoints +- name: Authentication + description: Login, MFA, and password management endpoints paths: /account: get: @@ -3432,7 +3434,124 @@ paths: 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: BearerAuth: 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/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/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 80e1e783..a13c1b13 100644 --- a/routes/login.js +++ b/routes/login.js @@ -13,4 +13,9 @@ router.post('/', loginLimiter, loginValidator, validate, controller.login); // POST /login/mfa router.post('/mfa', loginLimiter, mfaloginValidator, validate, controller.loginMfa); +// 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 f4209576..c035ad42 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.legacyPasswordHandler(req, res); + } +); + +module.exports = router; diff --git a/services/authService.js b/services/authService.js index f4023723..c9fcdf66 100644 --- a/services/authService.js +++ b/services/authService.js @@ -2,6 +2,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 { ServiceError } = require('./serviceError'); const supabaseAnon = createClient( @@ -18,6 +19,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'; } /* ========================= @@ -31,6 +34,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 ========================= */ @@ -306,17 +330,33 @@ class AuthService { /* ========================= Logout All ========================= */ - async logoutAll(userId) { + async logoutAll(userId, options = {}) { try { if (!userId) { throw new ServiceError(400, 'User ID is required'); } + 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) { if (error instanceof ServiceError) { @@ -327,6 +367,135 @@ class AuthService { } } + 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 new file mode 100644 index 00000000..e0c7b65e --- /dev/null +++ b/test/userPasswordController.test.js @@ -0,0 +1,207 @@ +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; + }, + json(payload) { + this.body = payload; + return this; + }, + send(payload) { + this.body = payload; + return this; + }, + clearCookie(name) { + this.cookiesCleared.push(name); + return this; + }, + }; + + return res; +}; + +describe("userPasswordController", () => { + let bcrypt; + let getUser; + let updateUser; + let authService; + let controller; + + beforeEach(() => { + bcrypt = { + compare: sinon.stub(), + hash: sinon.stub(), + }; + getUser = sinon.stub(); + updateUser = sinon.stub(); + authService = { + logoutAll: sinon.stub().resolves({ 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.resolves([{ password: "hashed-password", mfa_enabled: true }]); + bcrypt.compare.resolves(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.resolves([{ password: "hashed-password", mfa_enabled: false }]); + bcrypt.compare.resolves(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!", + confirm_password: "NewPass123!", + }, + }; + const res = createRes(); + + 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.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 () => { + const req = { + user: { userId: "user-123" }, + body: { + password: "CurrentPass123!", + new_password: "weak", + confirm_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!", + confirm_password: "CurrentPass123!", + }, + }; + const res = createRes(); + + await controller.updateUserPassword(req, res); + + 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"); + }); +});