diff --git a/server/controller/Auth/forgotPassword.js b/server/controller/Auth/forgotPassword.js index 7d660f27..c42dc2b8 100644 --- a/server/controller/Auth/forgotPassword.js +++ b/server/controller/Auth/forgotPassword.js @@ -26,9 +26,10 @@ const forgotPasswordLogic = async (req, res) => { // Generate 32-byte secure token const token = crypto.randomBytes(32).toString("hex"); + const hashedToken = crypto.createHash("sha256").update(token).digest("hex"); const expiry = Date.now() + 15 * 60 * 1000; // 15 min validity - user.resetToken = token; + user.resetToken = hashedToken; user.resetTokenExpiry = expiry; await user.save(); diff --git a/server/controller/Auth/resetPassword.js b/server/controller/Auth/resetPassword.js index 79357268..c52c13ea 100644 --- a/server/controller/Auth/resetPassword.js +++ b/server/controller/Auth/resetPassword.js @@ -1,6 +1,6 @@ -// controller/Auth/resetPassword.js const bcrypt = require("bcryptjs"); const UserModel = require("../../models/user.models"); +const crypto = require("crypto"); const { validatePassword } = require("../../utils/passwordValidator"); const resetPassword = async (req, res) => { @@ -24,8 +24,11 @@ const resetPassword = async (req, res) => { }); } - // Find user by token - const user = await UserModel.findOne({ resetToken: token }); + // Hash incoming token to match the database stored token + const hashedToken = crypto.createHash("sha256").update(token).digest("hex"); + + // Find user by hashed token + const user = await UserModel.findOne({ resetToken: hashedToken }); if (!user) { return res.status(400).json({ diff --git a/server/middleware/rateLimiter.js b/server/middleware/rateLimiter.js index 62e1e38d..6582a3b4 100644 --- a/server/middleware/rateLimiter.js +++ b/server/middleware/rateLimiter.js @@ -43,9 +43,14 @@ const compilerLimiter = rateLimit({ legacyHeaders: false, }); +feat/secure-forgot-password-1426 + + +// 1 minute window, max 5 feedback submissions per // Feedback Limiter const feedbackWindowMs = parseInt(process.env.FEEDBACK_RATE_LIMIT_WINDOW_MS) || 60 * 1000; // 1 minute const feedbackMaxRequests = parseInt(process.env.FEEDBACK_RATE_LIMIT_MAX_REQUESTS) || 5; +main const feedbackLimiter = rateLimit({ windowMs: feedbackWindowMs, max: feedbackMaxRequests, diff --git a/server/tests/passwordReset.test.js b/server/tests/passwordReset.test.js new file mode 100644 index 00000000..5dfe6612 --- /dev/null +++ b/server/tests/passwordReset.test.js @@ -0,0 +1,126 @@ +const request = require('supertest'); +const app = require('../index').backend; +const UserModel = require('../models/user.models'); +const nodemailer = require('nodemailer'); + +// Mock nodemailer +jest.mock('nodemailer', () => { + const sendMailMock = jest.fn().mockResolvedValue(true); + return { + createTransport: jest.fn().mockReturnValue({ + verify: jest.fn().mockResolvedValue(true), + sendMail: sendMailMock, + }), + _sendMailMock: sendMailMock, + }; +}); + +// Mock the User Model to avoid real DB connections +jest.mock('../models/user.models', () => { + let mockDB = {}; // Stores user state + + return { + findOne: jest.fn(async (query) => { + // Find by email or resetToken + if (query.$or) { + const email = query.$or[0].email || query.$or[1].Email; + return mockDB[email] || null; + } + if (query.resetToken) { + return Object.values(mockDB).find(u => u.resetToken === query.resetToken) || null; + } + return null; + }), + __setMockUser: (user) => { + mockDB[user.email] = { + ...user, + save: jest.fn().mockImplementation(async function() { + // Simulate saving by updating the mockDB with this object's state + mockDB[this.email] = this; + return this; + }) + }; + }, + __clearMock: () => { mockDB = {}; } + }; +}); + + +describe('Secure Password Reset Flow', () => { + beforeEach(() => { + jest.clearAllMocks(); + UserModel.__clearMock(); + + // Set dummy env vars to prevent 500 errors + process.env.EMAIL_USER = 'dummy@test.com'; + process.env.EMAIL_PASS = 'dummypass'; + + // Seed our mock user + UserModel.__setMockUser({ + username: 'reset_tester', + email: 'reset@test.com', + Email: 'reset@test.com', + password: 'OldPassword123!', + resetToken: undefined, + resetTokenExpiry: undefined + }); + }); + + test('POST /api/auth/forgot-password should generate hashed token and send email', async () => { + const res = await request(app) + .post('/api/auth/forgot-password') + .send({ email: 'reset@test.com' }); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + + const sendMailMock = require('nodemailer')._sendMailMock; + expect(sendMailMock).toHaveBeenCalledTimes(1); + + // Verify token was hashed in the mock DB + const dbUser = await UserModel.findOne({ $or: [{email: 'reset@test.com'}, {Email: 'reset@test.com'}] }); + expect(dbUser.resetToken).toBeDefined(); + expect(dbUser.resetToken).toMatch(/^[a-f0-9]{64}$/); // SHA-256 length + expect(dbUser.resetTokenExpiry).toBeDefined(); + }); + + test('POST /api/auth/reset-password should successfully reset password with valid token', async () => { + await request(app) + .post('/api/auth/forgot-password') + .send({ email: 'reset@test.com' }); + + const sendMailMock = require('nodemailer')._sendMailMock; + const mailOptions = sendMailMock.mock.calls[0][0]; + + // Extract raw token from email + const tokenMatch = mailOptions.html.match(/token=([a-f0-9]+)/); + const token = tokenMatch[1]; + + const resetRes = await request(app) + .post('/api/auth/reset-password') + .send({ + token, + newPassword: 'NewSecurePassword123!', + }); + + expect(resetRes.statusCode).toBe(200); + expect(resetRes.body.success).toBe(true); + + // Verify token is invalidated + const dbUser = await UserModel.findOne({ $or: [{email: 'reset@test.com'}, {Email: 'reset@test.com'}] }); + expect(dbUser.resetToken).toBeUndefined(); + expect(dbUser.resetTokenExpiry).toBeUndefined(); + }); + + test('POST /api/auth/reset-password should reject invalid token', async () => { + const resetRes = await request(app) + .post('/api/auth/reset-password') + .send({ + token: 'invalid_or_made_up_token', + newPassword: 'NewSecurePassword123!', + }); + + expect(resetRes.statusCode).toBe(400); + expect(resetRes.body.success).toBe(false); + }); +});