Files
Sizzy e22a8dc248 chnages
chnages
2026-07-01 01:10:13 +05:45

840 lines
31 KiB
JavaScript

// ============================================================
// authController.js — All authentication logic
// ============================================================
const axios = require('axios');
const jwt = require('jsonwebtoken');
const pool = require('../db');
const crypto = require('crypto');
const { hashPassword, verifyPassword, generateSalt } = require('../utils/hash');
const { generateOTP, getOTPExpiry, hashOTP, verifyOTPHash } = require('../utils/otp');
const { sendOTPEmail, sendChangePasswordLink, sendNewDeviceAlert } = require('../utils/email');
const { log, ACTIONS } = require('../utils/logger');
const { parseDevice } = require('../utils/deviceParser');
const {
validateEmail,
validatePassword,
getPasswordErrors,
isLeakedPassword,
sanitize,
} = require('../utils/validator');
const isDev = process.env.NODE_ENV !== 'production';
// Max OTP verification attempts before OTP is invalidated
const MAX_OTP_ATTEMPTS = 3;
// Minimum seconds between OTP resend requests per user
const OTP_RESEND_COOLDOWN_SEC = 60;
// Password expires after 90 days — user must change it
const PASSWORD_MAX_AGE_DAYS = 90;
// Helper: get client IP
function getIP(req) {
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown';
}
// ============================================================
// HELPER: Verify Cloudflare Turnstile CAPTCHA token
// ============================================================
async function verifyTurnstile(token) {
try {
const response = await axios.post(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
new URLSearchParams({
secret: process.env.TURNSTILE_SECRET_KEY,
response: token,
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
return response.data.success === true;
} catch (err) {
console.error('Turnstile verification error:', err.message);
return false;
}
}
// Helper: password validation error message
function passwordErrorMessage(password) {
const errors = getPasswordErrors(password);
if (errors.length === 0) return null;
return `Password must contain ${errors.join(', ')}.`;
}
// ============================================================
// POST /api/auth/login
// ============================================================
async function login(req, res, next) {
try {
let { email, password, turnstileToken } = req.body;
email = sanitize(email || '').toLowerCase().trim();
// Verify CAPTCHA
const captchaOk = await verifyTurnstile(turnstileToken);
if (!captchaOk) {
await log(null, ACTIONS.CAPTCHA_FAILED, 'failed', `CAPTCHA failed on login for: "${email}"`, req);
return res.status(400).json({ message: 'CAPTCHA verification failed. Please try again.' });
}
// Find user by email
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
if (result.rows.length === 0) {
await log(null, ACTIONS.LOGIN_FAILED, 'failed', `Login attempt for unknown email: "${email}"`, req);
return res.status(401).json({ message: 'Invalid email or password.' });
}
const user = result.rows[0];
// Block login if email is not verified
if (user.email_verified === false) {
await log(user.id, ACTIONS.LOGIN_FAILED, 'failed', `Login blocked — email not verified for "${email}"`, req);
return res.status(403).json({
message: 'Email not verified. Please verify your email with the OTP sent during registration.',
requireEmailVerification: true,
userId: user.id,
});
}
// Check account lock
if (user.locked_until && new Date() < new Date(user.locked_until)) {
const minsLeft = Math.ceil((new Date(user.locked_until) - new Date()) / 60000);
await log(user.id, ACTIONS.ACCOUNT_LOCKED, 'warning', `Login blocked — account locked for ${minsLeft} more min(s)`, req);
return res.status(423).json({ message: `Account is locked. Try again in ${minsLeft} minute(s).` });
}
// Verify password
const passwordOk = verifyPassword(password, user.password_hash);
if (!passwordOk) {
const newAttempts = user.failed_attempts + 1;
let lockUntil = null;
if (newAttempts >= 5) {
lockUntil = new Date(Date.now() + 15 * 60 * 1000);
}
await pool.query(
'UPDATE users SET failed_attempts = $1, locked_until = $2 WHERE id = $3',
[newAttempts, lockUntil, user.id]
);
if (lockUntil) {
await log(user.id, ACTIONS.ACCOUNT_LOCKED, 'warning', `Account locked after 5 failed attempts`, req);
return res.status(423).json({ message: 'Account locked for 15 minutes after 5 failed attempts.' });
}
const remaining = 5 - newAttempts;
await log(user.id, ACTIONS.LOGIN_FAILED, 'failed', `Wrong password — attempt ${newAttempts}/5 (${remaining} left)`, req);
return res.status(401).json({ message: `Invalid password. ${remaining} attempt(s) remaining before lockout.` });
}
// Password correct — reset failed attempts
await pool.query(
'UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = $1',
[user.id]
);
// Check password expiry (90 days)
if (user.password_changed_at) {
const daysSinceChange = (Date.now() - new Date(user.password_changed_at).getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceChange >= PASSWORD_MAX_AGE_DAYS) {
await log(user.id, ACTIONS.PASSWORD_EXPIRED, 'warning',
`Password expired (${Math.floor(daysSinceChange)} days old) for "${user.email}"`, req);
return res.status(403).json({
message: `Your password has expired (last changed ${Math.floor(daysSinceChange)} days ago). Please reset your password.`,
passwordExpired: true,
userId: user.id,
});
}
}
// Generate OTP, hash it, and store the hash in DB
const otp = generateOTP();
const otpExpiry = getOTPExpiry();
const otpHash = hashOTP(otp);
await pool.query(
'UPDATE users SET otp_code = $1, otp_expires_at = $2, otp_attempts = 0 WHERE id = $3',
[otpHash, otpExpiry, user.id]
);
// Send OTP via email
try {
await sendOTPEmail(user.email, user.username, otp);
if (isDev) console.log(`[OTP] Sent to ${user.email} — Code: ${otp}`);
} catch (emailErr) {
console.error('[OTP] Email failed:', emailErr.message);
if (isDev) console.log(`[OTP] Fallback — User: ${user.username} | Code: ${otp}`);
}
await log(user.id, ACTIONS.OTP_SENT, 'success', `OTP sent to ${user.email} after password verified`, req);
return res.json({
message: `OTP sent to your email (${user.email}).`,
userId: user.id,
requireOTP: true,
});
} catch (err) {
next(err);
}
}
// ============================================================
// POST /api/auth/request-otp (resend)
// ============================================================
async function requestOTP(req, res, next) {
try {
const { userId } = req.body;
const result = await pool.query(
'SELECT id, username, email, otp_expires_at FROM users WHERE id = $1',
[userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ message: 'User not found.' });
}
const user = result.rows[0];
// Rate-limit: enforce cooldown between OTP resends
// OTP expiry is 5 min from issue. If last OTP was issued less than
// OTP_RESEND_COOLDOWN_SEC ago, reject the request.
if (user.otp_expires_at) {
const issuedAt = new Date(user.otp_expires_at).getTime() - 5 * 60 * 1000; // when OTP was issued
const elapsed = (Date.now() - issuedAt) / 1000;
if (elapsed < OTP_RESEND_COOLDOWN_SEC) {
const wait = Math.ceil(OTP_RESEND_COOLDOWN_SEC - elapsed);
return res.status(429).json({
message: `Please wait ${wait} second(s) before requesting a new OTP.`,
});
}
}
// Generate new OTP, hash it, reset attempts
const otp = generateOTP();
const otpExpiry = getOTPExpiry();
const otpHash = hashOTP(otp);
await pool.query(
'UPDATE users SET otp_code = $1, otp_expires_at = $2, otp_attempts = 0 WHERE id = $3',
[otpHash, otpExpiry, userId]
);
try {
await sendOTPEmail(user.email, user.username, otp);
if (isDev) console.log(`[OTP] Resent to ${user.email} — Code: ${otp}`);
} catch (emailErr) {
console.error('[OTP] Email failed:', emailErr.message);
if (isDev) console.log(`[OTP] Fallback — User: ${user.username} | Code: ${otp}`);
}
await log(user.id, ACTIONS.OTP_RESENT, 'success', `OTP resent to ${user.email}`, req);
return res.json({ message: `New OTP sent to ${user.email}.` });
} catch (err) {
next(err);
}
}
// ============================================================
// POST /api/auth/verify-otp
// ============================================================
async function verifyOTP(req, res, next) {
try {
const { userId, otp } = req.body;
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
if (result.rows.length === 0) {
return res.status(404).json({ message: 'User not found.' });
}
const user = result.rows[0];
// Check OTP expiry first
if (!user.otp_expires_at || new Date() > new Date(user.otp_expires_at)) {
await log(user.id, ACTIONS.OTP_EXPIRED, 'failed', `OTP expired`, req);
return res.status(401).json({ message: 'OTP has expired. Please request a new one.' });
}
// Brute-force protection: check attempt count
const attempts = user.otp_attempts || 0;
if (attempts >= MAX_OTP_ATTEMPTS) {
// Invalidate the OTP
await pool.query(
'UPDATE users SET otp_code = NULL, otp_expires_at = NULL, otp_attempts = 0 WHERE id = $1',
[user.id]
);
await log(user.id, ACTIONS.OTP_FAILED, 'failed', `OTP invalidated after ${MAX_OTP_ATTEMPTS} failed attempts`, req);
return res.status(401).json({
message: `Too many wrong attempts. OTP has been invalidated. Please request a new one.`,
});
}
// Verify OTP hash
let otpValid = false;
try {
otpValid = user.otp_code && verifyOTPHash(otp, user.otp_code);
} catch {
otpValid = false;
}
if (!otpValid) {
// Increment attempt counter
await pool.query(
'UPDATE users SET otp_attempts = otp_attempts + 1 WHERE id = $1',
[user.id]
);
const remaining = MAX_OTP_ATTEMPTS - (attempts + 1);
await log(user.id, ACTIONS.OTP_FAILED, 'failed', `Wrong OTP entered (${remaining} attempt(s) remaining)`, req);
return res.status(401).json({
message: `Invalid OTP. ${remaining} attempt(s) remaining.`,
});
}
// Clear OTP
await pool.query(
'UPDATE users SET otp_code = NULL, otp_expires_at = NULL, otp_attempts = 0 WHERE id = $1',
[user.id]
);
// Generate a unique session ID (jti = JWT ID)
const jti = crypto.randomBytes(16).toString('hex');
// Issue JWT — includes jti so we can match it to a session row
const token = jwt.sign(
{ id: user.id, username: user.username, role: user.role, jti },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// Issue refresh token (longer lived)
const refreshJti = crypto.randomBytes(16).toString('hex');
const refreshToken = jwt.sign(
{ id: user.id, jti: refreshJti, type: 'refresh' },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
// Save session to DB with device + IP info, including refresh token jti
const deviceInfo = parseDevice(req.headers['user-agent']);
const clientIP = getIP(req);
// Check if this device + IP combo has been seen before for this user
const knownDevice = await pool.query(
`SELECT id FROM sessions
WHERE user_id = $1 AND device_info = $2 AND ip_address = $3
LIMIT 1`,
[user.id, deviceInfo, clientIP]
);
const isNewDevice = knownDevice.rows.length === 0;
await pool.query(
`INSERT INTO sessions (user_id, jti, refresh_jti, ip_address, device_info)
VALUES ($1, $2, $3, $4, $5)`,
[user.id, jti, refreshJti, clientIP, deviceInfo]
);
// Send new device alert email (non-blocking — don't fail login if email fails)
if (isNewDevice) {
sendNewDeviceAlert(user.email, user.username, deviceInfo, clientIP, new Date())
.then(() => {
if (isDev) console.log(`[NEW DEVICE] Alert sent to ${user.email} for "${deviceInfo}"`);
})
.catch((err) => {
console.error('[NEW DEVICE] Alert email failed:', err.message);
});
await log(user.id, ACTIONS.NEW_DEVICE_LOGIN, 'warning',
`New device login: ${deviceInfo} from ${clientIP}`, req);
}
const isProduction = process.env.NODE_ENV === 'production';
res.cookie('token', token, {
httpOnly: true,
secure: isProduction,
maxAge: 60 * 60 * 1000, // 1 hour
sameSite: isProduction ? 'strict' : 'lax',
});
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: isProduction,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
sameSite: isProduction ? 'strict' : 'lax',
path: '/api/auth/refresh-token', // only sent to refresh endpoint
});
await log(user.id, ACTIONS.LOGIN_SUCCESS, 'success',
`Login completed — role: ${user.role} — device: ${deviceInfo}`, req);
return res.json({
message: 'Login successful! Welcome.',
user: { id: user.id, username: user.username, role: user.role },
});
} catch (err) {
next(err);
}
}
// ============================================================
// POST /api/auth/refresh-token (public, requires refresh cookie)
// ============================================================
async function refreshToken(req, res, next) {
try {
const token = req.cookies.refreshToken;
if (!token) {
return res.status(401).json({ message: 'No refresh token.' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch {
return res.status(401).json({ message: 'Invalid or expired refresh token. Please login again.' });
}
if (decoded.type !== 'refresh') {
return res.status(401).json({ message: 'Invalid token type.' });
}
// Check session still exists by refresh_jti
const session = await pool.query(
'SELECT s.*, u.username, u.role FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.refresh_jti = $1',
[decoded.jti]
);
if (session.rows.length === 0) {
return res.status(401).json({ message: 'Session revoked. Please login again.' });
}
const sess = session.rows[0];
// Issue a new access token
const newJti = crypto.randomBytes(16).toString('hex');
const newToken = jwt.sign(
{ id: sess.user_id, username: sess.username, role: sess.role, jti: newJti },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// Update session jti
await pool.query(
'UPDATE sessions SET jti = $1, last_used_at = NOW() WHERE id = $2',
[newJti, sess.id]
);
const isProduction = process.env.NODE_ENV === 'production';
res.cookie('token', newToken, {
httpOnly: true,
secure: isProduction,
maxAge: 60 * 60 * 1000,
sameSite: isProduction ? 'strict' : 'lax',
});
return res.json({ message: 'Token refreshed.', user: { id: sess.user_id, username: sess.username, role: sess.role } });
} catch (err) {
next(err);
}
}
// ============================================================
// POST /api/auth/change-password (protected)
// ============================================================
async function changePassword(req, res, next) {
try {
const { oldPassword, newPassword } = req.body;
const userId = req.user.id;
if (!validatePassword(newPassword)) {
const msg = passwordErrorMessage(newPassword) || 'Password does not meet requirements.';
return res.status(400).json({ message: msg });
}
if (isLeakedPassword(newPassword)) {
await log(userId, ACTIONS.CHANGE_PASSWORD_FAILED, 'failed', `Leaked password used for change password`, req);
return res.status(400).json({ message: 'This password is commonly used and easy to crack. Please choose a stronger password.' });
}
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
if (result.rows.length === 0) {
return res.status(404).json({ message: 'User not found.' });
}
const user = result.rows[0];
// Verify old password
const oldPasswordOk = verifyPassword(oldPassword, user.password_hash);
if (!oldPasswordOk) {
await log(userId, ACTIONS.CHANGE_PASSWORD_FAILED, 'failed', `Wrong current password provided`, req);
return res.status(401).json({ message: 'Current password is incorrect.' });
}
// Check password history (last 5)
const history = await pool.query(
`SELECT password_hash FROM password_history
WHERE user_id = $1 ORDER BY created_at DESC LIMIT 5`,
[userId]
);
for (const record of history.rows) {
if (verifyPassword(newPassword, record.password_hash)) {
await log(userId, ACTIONS.CHANGE_PASSWORD_FAILED, 'failed', `Attempted to reuse a recent password`, req);
return res.status(400).json({ message: 'Cannot reuse a recent password. Please choose a different one.' });
}
}
// Hash and save new password + update password_changed_at
const newSalt = generateSalt();
const newPasswordHash = hashPassword(newPassword, newSalt);
await pool.query(
'UPDATE users SET password_hash = $1, password_changed_at = NOW() WHERE id = $2',
[newPasswordHash, userId]
);
await pool.query(
'INSERT INTO password_history (user_id, password_hash) VALUES ($1, $2)',
[userId, newPasswordHash]
);
// Invalidate ALL sessions (forces re-login on all devices)
const deleted = await pool.query(
'DELETE FROM sessions WHERE user_id = $1 RETURNING id',
[userId]
);
await log(userId, ACTIONS.CHANGE_PASSWORD, 'success',
`Password changed — ${deleted.rowCount} session(s) invalidated`, req);
res.clearCookie('token');
res.clearCookie('refreshToken', { path: '/api/auth/refresh-token' });
return res.json({
message: 'Password changed successfully! You have been logged out of all devices for security.',
loggedOut: true,
});
} catch (err) {
next(err);
}
}
// ============================================================
// POST /api/auth/forgot-password (public)
// ============================================================
async function forgotPassword(req, res, next) {
try {
let { email } = req.body;
email = sanitize(email || '').toLowerCase().trim();
if (!validateEmail(email)) {
return res.status(400).json({ message: 'Please enter a valid email address.' });
}
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
if (result.rows.length === 0) {
await log(null, ACTIONS.FORGOT_PASSWORD_FAILED, 'failed',
`Reset requested for unknown email: "${email}"`, req);
return res.json({ message: 'If that email is registered, an OTP has been sent.' });
}
const user = result.rows[0];
// Rate-limit: enforce cooldown between OTP resends
if (user.otp_expires_at) {
const issuedAt = new Date(user.otp_expires_at).getTime() - 5 * 60 * 1000;
const elapsed = (Date.now() - issuedAt) / 1000;
if (elapsed < OTP_RESEND_COOLDOWN_SEC) {
return res.json({ message: 'If that email is registered, an OTP has been sent.' });
}
}
// Generate OTP, hash it, and store
const otp = generateOTP();
const otpExpiry = getOTPExpiry();
const otpHash = hashOTP(otp);
await pool.query(
'UPDATE users SET otp_code = $1, otp_expires_at = $2, otp_attempts = 0 WHERE id = $3',
[otpHash, otpExpiry, user.id]
);
try {
await sendOTPEmail(user.email, user.username, otp);
if (isDev) console.log(`[RESET OTP] Sent to ${user.email} — Code: ${otp}`);
} catch (emailErr) {
console.error('[RESET OTP] Email failed:', emailErr.message);
if (isDev) console.log(`[RESET OTP] Fallback — User: ${user.username} | Code: ${otp}`);
}
await log(user.id, ACTIONS.FORGOT_PASSWORD, 'success',
`Password reset OTP sent to ${user.email}`, req);
return res.json({
message: 'OTP sent to your email. Enter it below to reset your password.',
userId: user.id,
});
} catch (err) {
next(err);
}
}
// ============================================================
// POST /api/auth/reset-password (public)
// ============================================================
async function resetPassword(req, res, next) {
try {
const { userId, otp, newPassword } = req.body;
if (!validatePassword(newPassword)) {
const msg = passwordErrorMessage(newPassword) || 'Password does not meet requirements.';
return res.status(400).json({ message: msg });
}
if (isLeakedPassword(newPassword)) {
await log(userId, ACTIONS.RESET_PASSWORD_FAILED, 'failed', `Leaked password used for reset password`, req);
return res.status(400).json({ message: 'This password is commonly used and easy to crack. Please choose a stronger password.' });
}
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
if (result.rows.length === 0) {
return res.status(404).json({ message: 'User not found.' });
}
const user = result.rows[0];
// Check OTP expiry
if (!user.otp_expires_at || new Date() > new Date(user.otp_expires_at)) {
await log(user.id, ACTIONS.RESET_PASSWORD_FAILED, 'failed', `Expired OTP on password reset`, req);
return res.status(401).json({ message: 'OTP has expired. Please request a new one.' });
}
// Brute-force protection
const attempts = user.otp_attempts || 0;
if (attempts >= MAX_OTP_ATTEMPTS) {
await pool.query(
'UPDATE users SET otp_code = NULL, otp_expires_at = NULL, otp_attempts = 0 WHERE id = $1',
[user.id]
);
await log(user.id, ACTIONS.RESET_PASSWORD_FAILED, 'failed', `OTP invalidated after ${MAX_OTP_ATTEMPTS} failed attempts`, req);
return res.status(401).json({
message: `Too many wrong attempts. OTP has been invalidated. Please request a new one.`,
});
}
// Verify OTP hash
let otpValid = false;
try {
otpValid = user.otp_code && verifyOTPHash(otp, user.otp_code);
} catch {
otpValid = false;
}
if (!otpValid) {
await pool.query(
'UPDATE users SET otp_attempts = otp_attempts + 1 WHERE id = $1',
[user.id]
);
const remaining = MAX_OTP_ATTEMPTS - (attempts + 1);
await log(user.id, ACTIONS.RESET_PASSWORD_FAILED, 'failed', `Wrong OTP on password reset (${remaining} attempt(s) remaining)`, req);
return res.status(401).json({ message: `Invalid OTP. ${remaining} attempt(s) remaining.` });
}
// Check password history
const history = await pool.query(
`SELECT password_hash FROM password_history
WHERE user_id = $1 ORDER BY created_at DESC LIMIT 5`,
[userId]
);
for (const record of history.rows) {
if (verifyPassword(newPassword, record.password_hash)) {
await log(user.id, ACTIONS.RESET_PASSWORD_FAILED, 'failed',
`Attempted to reuse a recent password during reset`, req);
return res.status(400).json({
message: 'Cannot reuse a recent password. Please choose a different one.',
});
}
}
// Hash and save the new password
const newSalt = generateSalt();
const newPasswordHash = hashPassword(newPassword, newSalt);
await pool.query('UPDATE users SET password_hash = $1, otp_code = NULL, otp_expires_at = NULL, otp_attempts = 0, password_changed_at = NOW() WHERE id = $2',
[newPasswordHash, userId]);
await pool.query(
'INSERT INTO password_history (user_id, password_hash) VALUES ($1, $2)',
[userId, newPasswordHash]
);
await log(user.id, ACTIONS.RESET_PASSWORD, 'success',
`Password reset successfully via OTP for "${user.username}"`, req);
return res.json({ message: 'Password reset successful! You can now login with your new password.' });
} catch (err) {
next(err);
}
}
// ============================================================
// POST /api/auth/logout (protected)
// ============================================================
async function logout(req, res, next) {
try {
await pool.query('DELETE FROM sessions WHERE jti = $1', [req.userJti]);
await log(req.user.id, ACTIONS.LOGOUT, 'success', `User logged out from one device`, req);
res.clearCookie('token');
res.clearCookie('refreshToken', { path: '/api/auth/refresh-token' });
return res.json({ message: 'Logged out successfully.' });
} catch (err) {
next(err);
}
}
// ============================================================
// POST /api/auth/logout-all (protected)
// ============================================================
async function logoutAll(req, res, next) {
try {
const result = await pool.query(
'DELETE FROM sessions WHERE user_id = $1 RETURNING id',
[req.user.id]
);
const count = result.rowCount;
await log(req.user.id, ACTIONS.LOGOUT_ALL, 'success',
`Logged out from all ${count} device(s)`, req);
res.clearCookie('token');
res.clearCookie('refreshToken', { path: '/api/auth/refresh-token' });
return res.json({ message: `Logged out from all ${count} device(s).` });
} catch (err) {
next(err);
}
}
// ============================================================
// GET /api/auth/sessions (protected)
// ============================================================
async function getSessions(req, res, next) {
try {
const result = await pool.query(
`SELECT id, jti, ip_address, device_info, created_at, last_used_at
FROM sessions
WHERE user_id = $1
ORDER BY last_used_at DESC`,
[req.user.id]
);
const sessions = result.rows.map(s => ({
...s,
isCurrent: s.jti === req.userJti,
}));
return res.json({ sessions });
} catch (err) {
next(err);
}
}
// ============================================================
// GET /api/auth/me (protected)
// ============================================================
async function getMe(req, res) {
return res.json({ user: req.user });
}
// ============================================================
// POST /api/auth/request-change-password-link (protected)
// ============================================================
async function requestChangePasswordLink(req, res, next) {
try {
const userId = req.user.id;
const result = await pool.query('SELECT username, email, password_hash FROM users WHERE id = $1', [userId]);
if (result.rows.length === 0) return res.status(404).json({ message: 'User not found.' });
const user = result.rows[0];
const secret = process.env.JWT_SECRET + user.password_hash;
const token = jwt.sign({ id: userId, type: 'change-password-link' }, secret, { expiresIn: '15m' });
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5002';
const link = `${frontendUrl}/set-new-password?token=${token}&id=${userId}`;
try {
await sendChangePasswordLink(user.email, user.username, link);
if (isDev) console.log(`[CHANGE PWD LINK] Sent to ${user.email} — Link: ${link}`);
} catch (emailErr) {
console.error('[CHANGE PWD LINK] Email failed:', emailErr.message);
if (isDev) console.log(`[CHANGE PWD LINK] Fallback — User: ${user.username} | Link: ${link}`);
}
await log(userId, ACTIONS.CHANGE_PASSWORD, 'success', `Change password link sent to ${user.email}`, req);
return res.json({ message: 'A secure password change link has been sent to your email. Please check your inbox.' });
} catch (err) {
next(err);
}
}
// ============================================================
// POST /api/auth/verify-change-password-link (public, but requires token)
// ============================================================
async function verifyChangePasswordLink(req, res, next) {
try {
const { id, token, newPassword } = req.body;
if (!validatePassword(newPassword)) {
const msg = passwordErrorMessage(newPassword) || 'Password does not meet requirements.';
return res.status(400).json({ message: msg });
}
if (isLeakedPassword(newPassword)) {
await log(id, ACTIONS.CHANGE_PASSWORD_FAILED, 'failed', `Leaked password used for email link reset`, req);
return res.status(400).json({ message: 'This password is commonly used and easy to crack. Please choose a stronger password.' });
}
const result = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
if (result.rows.length === 0) return res.status(404).json({ message: 'User not found.' });
const user = result.rows[0];
const secret = process.env.JWT_SECRET + user.password_hash;
try {
const decoded = jwt.verify(token, secret);
if (decoded.id !== parseInt(id) || decoded.type !== 'change-password-link') {
throw new Error('Invalid token payload');
}
} catch (tokenErr) {
await log(id, ACTIONS.CHANGE_PASSWORD_FAILED, 'failed', `Invalid or expired password change link`, req);
return res.status(400).json({ message: 'The secure link is invalid or has expired. Please request a new one.' });
}
// Check password history (last 5)
const history = await pool.query(
`SELECT password_hash FROM password_history
WHERE user_id = $1 ORDER BY created_at DESC LIMIT 5`,
[id]
);
for (const record of history.rows) {
if (verifyPassword(newPassword, record.password_hash)) {
await log(user.id, ACTIONS.CHANGE_PASSWORD_FAILED, 'failed', `Attempted to reuse a recent password during link reset`, req);
return res.status(400).json({ message: 'Cannot reuse a recent password. Please choose a different one.' });
}
}
// Hash and save new password
const newSalt = generateSalt();
const newPasswordHash = hashPassword(newPassword, newSalt);
await pool.query('UPDATE users SET password_hash = $1, password_changed_at = NOW() WHERE id = $2', [newPasswordHash, id]);
await pool.query(
'INSERT INTO password_history (user_id, password_hash) VALUES ($1, $2)',
[id, newPasswordHash]
);
// Logout all active sessions for security
await pool.query('DELETE FROM sessions WHERE user_id = $1', [id]);
await log(id, ACTIONS.CHANGE_PASSWORD, 'success', `Password changed successfully via email link`, req);
return res.json({ message: 'Password changed successfully! You will now be logged out of all devices.' });
} catch (err) {
next(err);
}
}
module.exports = {
login, requestOTP, verifyOTP, refreshToken,
changePassword, forgotPassword, resetPassword,
requestChangePasswordLink, verifyChangePasswordLink,
logout, logoutAll, getSessions, getMe,
};