Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion server/controller/Auth/forgotPassword.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
9 changes: 6 additions & 3 deletions server/controller/Auth/resetPassword.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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({
Expand Down
5 changes: 5 additions & 0 deletions server/middleware/rateLimiter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
126 changes: 126 additions & 0 deletions server/tests/passwordReset.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading