153 lines
3.3 KiB
JavaScript
153 lines
3.3 KiB
JavaScript
|
|
/**
|
||
|
|
* Brute force protection middleware
|
||
|
|
* Tracks failed login attempts and temporarily blocks IPs with too many failures
|
||
|
|
*/
|
||
|
|
|
||
|
|
const logger = require("../config/logger");
|
||
|
|
|
||
|
|
// Store failed attempts in memory (use Redis in production)
|
||
|
|
const failedAttempts = new Map();
|
||
|
|
const blockedIPs = new Map();
|
||
|
|
|
||
|
|
// Configuration
|
||
|
|
const MAX_FAILED_ATTEMPTS = 5;
|
||
|
|
const BLOCK_DURATION = 15 * 60 * 1000; // 15 minutes
|
||
|
|
const ATTEMPT_WINDOW = 15 * 60 * 1000; // 15 minutes
|
||
|
|
const CLEANUP_INTERVAL = 60 * 1000; // 1 minute
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Clean up old entries periodically
|
||
|
|
*/
|
||
|
|
const cleanup = () => {
|
||
|
|
const now = Date.now();
|
||
|
|
|
||
|
|
// Clean up failed attempts
|
||
|
|
for (const [ip, data] of failedAttempts.entries()) {
|
||
|
|
if (now - data.firstAttempt > ATTEMPT_WINDOW) {
|
||
|
|
failedAttempts.delete(ip);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clean up blocked IPs
|
||
|
|
for (const [ip, blockTime] of blockedIPs.entries()) {
|
||
|
|
if (now - blockTime > BLOCK_DURATION) {
|
||
|
|
blockedIPs.delete(ip);
|
||
|
|
logger.info("IP unblocked after cooldown", { ip });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Start cleanup interval
|
||
|
|
setInterval(cleanup, CLEANUP_INTERVAL);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Record a failed login attempt
|
||
|
|
* @param {string} ip - IP address
|
||
|
|
*/
|
||
|
|
const recordFailedAttempt = (ip) => {
|
||
|
|
const now = Date.now();
|
||
|
|
|
||
|
|
if (!failedAttempts.has(ip)) {
|
||
|
|
failedAttempts.set(ip, {
|
||
|
|
count: 1,
|
||
|
|
firstAttempt: now,
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
const data = failedAttempts.get(ip);
|
||
|
|
|
||
|
|
// Reset if outside window
|
||
|
|
if (now - data.firstAttempt > ATTEMPT_WINDOW) {
|
||
|
|
data.count = 1;
|
||
|
|
data.firstAttempt = now;
|
||
|
|
} else {
|
||
|
|
data.count++;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Block if too many attempts
|
||
|
|
if (data.count >= MAX_FAILED_ATTEMPTS) {
|
||
|
|
blockedIPs.set(ip, now);
|
||
|
|
logger.warn("IP blocked due to failed login attempts", {
|
||
|
|
ip,
|
||
|
|
attempts: data.count,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Reset failed attempts for an IP (on successful login)
|
||
|
|
* @param {string} ip - IP address
|
||
|
|
*/
|
||
|
|
const resetFailedAttempts = (ip) => {
|
||
|
|
failedAttempts.delete(ip);
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if an IP is currently blocked
|
||
|
|
* @param {string} ip - IP address
|
||
|
|
* @returns {boolean}
|
||
|
|
*/
|
||
|
|
const isBlocked = (ip) => {
|
||
|
|
if (!blockedIPs.has(ip)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const blockTime = blockedIPs.get(ip);
|
||
|
|
const now = Date.now();
|
||
|
|
|
||
|
|
// Check if block has expired
|
||
|
|
if (now - blockTime > BLOCK_DURATION) {
|
||
|
|
blockedIPs.delete(ip);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get remaining block time in seconds
|
||
|
|
* @param {string} ip - IP address
|
||
|
|
* @returns {number} Seconds remaining
|
||
|
|
*/
|
||
|
|
const getRemainingBlockTime = (ip) => {
|
||
|
|
if (!blockedIPs.has(ip)) {
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
const blockTime = blockedIPs.get(ip);
|
||
|
|
const elapsed = Date.now() - blockTime;
|
||
|
|
const remaining = Math.max(0, BLOCK_DURATION - elapsed);
|
||
|
|
|
||
|
|
return Math.ceil(remaining / 1000);
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Middleware to check if IP is blocked
|
||
|
|
*/
|
||
|
|
const checkBlocked = (req, res, next) => {
|
||
|
|
const ip = req.ip || req.connection.remoteAddress;
|
||
|
|
|
||
|
|
if (isBlocked(ip)) {
|
||
|
|
const remainingSeconds = getRemainingBlockTime(ip);
|
||
|
|
logger.warn("Blocked IP attempted access", { ip, path: req.path });
|
||
|
|
|
||
|
|
return res.status(429).json({
|
||
|
|
success: false,
|
||
|
|
message: `Too many failed attempts. Please try again in ${Math.ceil(
|
||
|
|
remainingSeconds / 60
|
||
|
|
)} minutes.`,
|
||
|
|
retryAfter: remainingSeconds,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
next();
|
||
|
|
};
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
recordFailedAttempt,
|
||
|
|
resetFailedAttempts,
|
||
|
|
isBlocked,
|
||
|
|
checkBlocked,
|
||
|
|
getRemainingBlockTime,
|
||
|
|
};
|