839 lines
31 KiB
JavaScript
839 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,
|
||
|
|
};
|