350 lines
9.7 KiB
JavaScript
350 lines
9.7 KiB
JavaScript
|
|
/**
|
||
|
|
* Enhanced Security Middleware
|
||
|
|
* Implements account lockout, password expiry, and session management
|
||
|
|
*/
|
||
|
|
|
||
|
|
const { db } = require('../database/db');
|
||
|
|
const { ACCOUNT_LOCKOUT, PASSWORD_EXPIRY, SESSION_POLICY } = require('../utils/passwordPolicy');
|
||
|
|
const SecurityAuditLogger = require('../utils/securityAudit');
|
||
|
|
const logger = require('../utils/logger');
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if account is locked
|
||
|
|
*/
|
||
|
|
async function checkAccountLockout(userId) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
db.get(
|
||
|
|
'SELECT locked_until FROM users WHERE id = ?',
|
||
|
|
[userId],
|
||
|
|
(err, user) => {
|
||
|
|
if (err) return reject(err);
|
||
|
|
if (!user) return resolve({ locked: false });
|
||
|
|
|
||
|
|
if (user.locked_until) {
|
||
|
|
const lockoutEnd = new Date(user.locked_until);
|
||
|
|
const now = new Date();
|
||
|
|
|
||
|
|
if (now < lockoutEnd) {
|
||
|
|
const remainingMinutes = Math.ceil((lockoutEnd - now) / 60000);
|
||
|
|
return resolve({
|
||
|
|
locked: true,
|
||
|
|
remainingMinutes,
|
||
|
|
message: `Account locked. Try again in ${remainingMinutes} minutes.`
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
// Lockout expired, clear it
|
||
|
|
db.run('UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE id = ?', [userId]);
|
||
|
|
return resolve({ locked: false });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
resolve({ locked: false });
|
||
|
|
}
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Record failed login attempt
|
||
|
|
*/
|
||
|
|
async function recordFailedLogin(username, ip, userAgent) {
|
||
|
|
try {
|
||
|
|
// Log to audit
|
||
|
|
await SecurityAuditLogger.logLoginFailure(username, 'Invalid credentials', { ip, userAgent });
|
||
|
|
|
||
|
|
// Get user ID
|
||
|
|
const user = await new Promise((resolve, reject) => {
|
||
|
|
db.get('SELECT id, failed_login_attempts FROM users WHERE username = ? OR email = ?', [username, username], (err, row) => {
|
||
|
|
if (err) reject(err);
|
||
|
|
else resolve(row);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!user) return;
|
||
|
|
|
||
|
|
const failedAttempts = (user.failed_login_attempts || 0) + 1;
|
||
|
|
|
||
|
|
// Update failed attempts
|
||
|
|
await new Promise((resolve, reject) => {
|
||
|
|
db.run(
|
||
|
|
'UPDATE users SET failed_login_attempts = ?, last_failed_login = ? WHERE id = ?',
|
||
|
|
[failedAttempts, new Date().toISOString(), user.id],
|
||
|
|
(err) => err ? reject(err) : resolve()
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Check if lockout threshold reached
|
||
|
|
if (failedAttempts >= ACCOUNT_LOCKOUT.maxFailedAttempts) {
|
||
|
|
const lockUntil = new Date(Date.now() + ACCOUNT_LOCKOUT.lockoutDuration).toISOString();
|
||
|
|
|
||
|
|
await new Promise((resolve, reject) => {
|
||
|
|
db.run(
|
||
|
|
'UPDATE users SET locked_until = ? WHERE id = ?',
|
||
|
|
[lockUntil, user.id],
|
||
|
|
(err) => err ? reject(err) : resolve()
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
await SecurityAuditLogger.logAccountLockout(user.id, {
|
||
|
|
ip,
|
||
|
|
userAgent,
|
||
|
|
failedAttempts
|
||
|
|
});
|
||
|
|
|
||
|
|
logger.warn(`Account locked for user ${username} after ${failedAttempts} failed attempts`);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
logger.error('Error recording failed login:', error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Clear failed login attempts on successful login
|
||
|
|
*/
|
||
|
|
async function clearFailedAttempts(userId) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
db.run(
|
||
|
|
'UPDATE users SET failed_login_attempts = 0, last_failed_login = NULL WHERE id = ?',
|
||
|
|
[userId],
|
||
|
|
(err) => err ? reject(err) : resolve()
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if password has expired
|
||
|
|
*/
|
||
|
|
async function checkPasswordExpiry(userId) {
|
||
|
|
if (!PASSWORD_EXPIRY.enabled) {
|
||
|
|
return { expired: false, warning: false };
|
||
|
|
}
|
||
|
|
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
db.get(
|
||
|
|
'SELECT password_changed_at, password_expires_at FROM users WHERE id = ?',
|
||
|
|
[userId],
|
||
|
|
(err, user) => {
|
||
|
|
if (err) return reject(err);
|
||
|
|
if (!user) return resolve({ expired: false, warning: false });
|
||
|
|
|
||
|
|
const now = new Date();
|
||
|
|
let expiryDate;
|
||
|
|
|
||
|
|
if (user.password_expires_at) {
|
||
|
|
expiryDate = new Date(user.password_expires_at);
|
||
|
|
} else if (user.password_changed_at) {
|
||
|
|
expiryDate = new Date(user.password_changed_at);
|
||
|
|
expiryDate.setDate(expiryDate.getDate() + PASSWORD_EXPIRY.expiryDays);
|
||
|
|
} else {
|
||
|
|
// No password change date, set expiry from now
|
||
|
|
expiryDate = new Date();
|
||
|
|
expiryDate.setDate(expiryDate.getDate() + PASSWORD_EXPIRY.expiryDays);
|
||
|
|
}
|
||
|
|
|
||
|
|
const daysUntilExpiry = Math.ceil((expiryDate - now) / (24 * 60 * 60 * 1000));
|
||
|
|
|
||
|
|
if (daysUntilExpiry <= 0) {
|
||
|
|
return resolve({
|
||
|
|
expired: true,
|
||
|
|
warning: false,
|
||
|
|
message: 'Your password has expired. Please change it to continue.',
|
||
|
|
gracePeriodDays: PASSWORD_EXPIRY.gracePeriodDays
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (daysUntilExpiry <= PASSWORD_EXPIRY.warningDays) {
|
||
|
|
return resolve({
|
||
|
|
expired: false,
|
||
|
|
warning: true,
|
||
|
|
daysRemaining: daysUntilExpiry,
|
||
|
|
message: `Your password will expire in ${daysUntilExpiry} days. Please change it soon.`
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
resolve({ expired: false, warning: false });
|
||
|
|
}
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update password expiry date
|
||
|
|
*/
|
||
|
|
async function updatePasswordExpiry(userId) {
|
||
|
|
if (!PASSWORD_EXPIRY.enabled) return;
|
||
|
|
|
||
|
|
const now = new Date();
|
||
|
|
const expiryDate = new Date(now);
|
||
|
|
expiryDate.setDate(expiryDate.getDate() + PASSWORD_EXPIRY.expiryDays);
|
||
|
|
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
db.run(
|
||
|
|
'UPDATE users SET password_changed_at = ?, password_expires_at = ? WHERE id = ?',
|
||
|
|
[now.toISOString(), expiryDate.toISOString(), userId],
|
||
|
|
(err) => err ? reject(err) : resolve()
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Middleware: Check account lockout before authentication
|
||
|
|
*/
|
||
|
|
const enforceAccountLockout = async (req, res, next) => {
|
||
|
|
const { username } = req.body;
|
||
|
|
|
||
|
|
if (!username) {
|
||
|
|
return next();
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Get user
|
||
|
|
const user = await new Promise((resolve, reject) => {
|
||
|
|
db.get('SELECT id FROM users WHERE username = ? OR email = ?', [username, username], (err, row) => {
|
||
|
|
if (err) reject(err);
|
||
|
|
else resolve(row);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!user) {
|
||
|
|
return next(); // User doesn't exist, let auth handle it
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check lockout
|
||
|
|
const lockoutStatus = await checkAccountLockout(user.id);
|
||
|
|
|
||
|
|
if (lockoutStatus.locked) {
|
||
|
|
return res.status(423).json({
|
||
|
|
error: lockoutStatus.message,
|
||
|
|
remainingMinutes: lockoutStatus.remainingMinutes,
|
||
|
|
locked: true
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
next();
|
||
|
|
} catch (error) {
|
||
|
|
logger.error('Account lockout check error:', error);
|
||
|
|
next();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Middleware: Check password expiry after authentication
|
||
|
|
*/
|
||
|
|
const enforcePasswordExpiry = async (req, res, next) => {
|
||
|
|
if (!req.user || !req.user.userId) {
|
||
|
|
return next();
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const expiryStatus = await checkPasswordExpiry(req.user.userId);
|
||
|
|
|
||
|
|
if (expiryStatus.expired) {
|
||
|
|
return res.status(403).json({
|
||
|
|
error: expiryStatus.message,
|
||
|
|
passwordExpired: true,
|
||
|
|
gracePeriodDays: expiryStatus.gracePeriodDays,
|
||
|
|
requirePasswordChange: true
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (expiryStatus.warning) {
|
||
|
|
// Add warning header but allow request
|
||
|
|
res.setHeader('X-Password-Expiry-Warning', expiryStatus.message);
|
||
|
|
res.setHeader('X-Password-Days-Remaining', expiryStatus.daysRemaining.toString());
|
||
|
|
}
|
||
|
|
|
||
|
|
next();
|
||
|
|
} catch (error) {
|
||
|
|
logger.error('Password expiry check error:', error);
|
||
|
|
next();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Manage active sessions
|
||
|
|
*/
|
||
|
|
async function createSession(userId, token, req) {
|
||
|
|
const ip = req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress;
|
||
|
|
const userAgent = req.headers['user-agent'];
|
||
|
|
const expiresAt = new Date(Date.now() + SESSION_POLICY.absoluteTimeout);
|
||
|
|
|
||
|
|
// Check concurrent sessions
|
||
|
|
const activeSessions = await new Promise((resolve, reject) => {
|
||
|
|
db.all(
|
||
|
|
'SELECT COUNT(*) as count FROM active_sessions WHERE user_id = ? AND expires_at > ?',
|
||
|
|
[userId, new Date().toISOString()],
|
||
|
|
(err, rows) => err ? reject(err) : resolve(rows[0].count)
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
if (activeSessions >= SESSION_POLICY.maxConcurrentSessions) {
|
||
|
|
// Remove oldest session
|
||
|
|
await new Promise((resolve, reject) => {
|
||
|
|
db.run(
|
||
|
|
'DELETE FROM active_sessions WHERE id IN (SELECT id FROM active_sessions WHERE user_id = ? ORDER BY last_activity ASC LIMIT 1)',
|
||
|
|
[userId],
|
||
|
|
(err) => err ? reject(err) : resolve()
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create new session
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
db.run(
|
||
|
|
'INSERT INTO active_sessions (user_id, session_token, ip_address, user_agent, expires_at) VALUES (?, ?, ?, ?, ?)',
|
||
|
|
[userId, token, ip, userAgent, expiresAt.toISOString()],
|
||
|
|
(err) => err ? reject(err) : resolve()
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update session activity
|
||
|
|
*/
|
||
|
|
async function updateSessionActivity(token) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
db.run(
|
||
|
|
'UPDATE active_sessions SET last_activity = ? WHERE session_token = ?',
|
||
|
|
[new Date().toISOString(), token],
|
||
|
|
(err) => err ? reject(err) : resolve()
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Cleanup expired sessions
|
||
|
|
*/
|
||
|
|
async function cleanupExpiredSessions() {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
db.run(
|
||
|
|
'DELETE FROM active_sessions WHERE expires_at < ?',
|
||
|
|
[new Date().toISOString()],
|
||
|
|
function(err) {
|
||
|
|
if (err) reject(err);
|
||
|
|
else {
|
||
|
|
if (this.changes > 0) {
|
||
|
|
logger.info(`Cleaned up ${this.changes} expired sessions`);
|
||
|
|
}
|
||
|
|
resolve(this.changes);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Run session cleanup every hour
|
||
|
|
setInterval(cleanupExpiredSessions, 60 * 60 * 1000);
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
checkAccountLockout,
|
||
|
|
recordFailedLogin,
|
||
|
|
clearFailedAttempts,
|
||
|
|
checkPasswordExpiry,
|
||
|
|
updatePasswordExpiry,
|
||
|
|
enforceAccountLockout,
|
||
|
|
enforcePasswordExpiry,
|
||
|
|
createSession,
|
||
|
|
updateSessionActivity,
|
||
|
|
cleanupExpiredSessions
|
||
|
|
};
|