Files
EMI-Backend/middleware/authRateLimiter.js
2026-02-20 21:20:21 -05:00

117 lines
4.1 KiB
JavaScript

const crypto = require('crypto');
const { client_logger } = require('../utils/analyticsLogger');
const AUTH_ATTEMPT_WINDOW_MS = Math.max(60 * 1000, parseInt(process.env.AUTH_ATTEMPT_WINDOW_MS || `${15 * 60 * 1000}`, 10));
const AUTH_ATTEMPT_MAX = Math.max(1, parseInt(process.env.AUTH_ATTEMPT_MAX || '5', 10));
const AUTH_BLOCK_BASE_MS = Math.max(30 * 1000, parseInt(process.env.AUTH_BLOCK_BASE_MS || `${5 * 60 * 1000}`, 10));
const AUTH_BLOCK_MAX_MS = Math.max(AUTH_BLOCK_BASE_MS, parseInt(process.env.AUTH_BLOCK_MAX_MS || `${60 * 60 * 1000}`, 10));
const limiterStore = new Map();
let lastPruneAt = 0;
const getClientIp = (req) => {
const forwarded = req.headers['x-forwarded-for']?.split(',')[0]?.trim();
const rawIp = forwarded || req.ip || req.connection?.remoteAddress || 'unknown';
return rawIp.replace('::ffff:', '');
};
const hashValue = (value) =>
crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 16);
const getIdentity = (req, mode) => {
if (mode === 'token') {
const token = (req.body?.token || '').trim();
return token ? `token:${hashValue(token)}` : 'token:anonymous';
}
const username = (req.body?.username || req.body?.email || '').trim().toLowerCase();
return username ? `acct:${hashValue(username)}` : 'acct:anonymous';
};
const getLimiterKey = (req, mode) => `${mode}:${getIdentity(req, mode)}:ip:${getClientIp(req)}`;
const getOrInitRecord = (key, now) => {
const existing = limiterStore.get(key);
if (existing) {
return existing;
}
const record = {
count: 0,
windowStartedAt: now,
blockedUntil: 0,
blockLevel: 0,
};
limiterStore.set(key, record);
return record;
};
const computeBlockMs = (blockLevel) =>
Math.min(AUTH_BLOCK_BASE_MS * (2 ** Math.max(0, blockLevel - 1)), AUTH_BLOCK_MAX_MS);
const authRateLimiter = (mode) => (req, res, next) => {
const now = Date.now();
if (now - lastPruneAt > 5 * 60 * 1000) {
for (const [storeKey, storeValue] of limiterStore.entries()) {
const isWindowExpired = now - storeValue.windowStartedAt > AUTH_ATTEMPT_WINDOW_MS;
const isNotBlocked = storeValue.blockedUntil <= now;
if (isWindowExpired && isNotBlocked) {
limiterStore.delete(storeKey);
}
}
lastPruneAt = now;
}
const key = getLimiterKey(req, mode);
const record = getOrInitRecord(key, now);
if (now - record.windowStartedAt > AUTH_ATTEMPT_WINDOW_MS) {
record.count = 0;
record.windowStartedAt = now;
}
if (record.blockedUntil > now) {
const retryAfterSec = Math.ceil((record.blockedUntil - now) / 1000);
res.set('Retry-After', retryAfterSec.toString());
client_logger.capture({
distinctId: 'app_level',
event: 'security@auth@rate_limited',
properties: {
route: req.originalUrl,
method: req.method,
mode,
keyHash: hashValue(key),
retryAfterSec,
blockLevel: record.blockLevel,
}
});
return res.status(429).json({ status: 'Too many attempts. Please try again later.' });
}
record.count += 1;
if (record.count > AUTH_ATTEMPT_MAX) {
record.blockLevel += 1;
const blockMs = computeBlockMs(record.blockLevel);
record.blockedUntil = now + blockMs;
record.count = 0;
record.windowStartedAt = now;
res.set('Retry-After', Math.ceil(blockMs / 1000).toString());
client_logger.capture({
distinctId: 'app_level',
event: 'security@auth@rate_limited',
properties: {
route: req.originalUrl,
method: req.method,
mode,
keyHash: hashValue(key),
retryAfterSec: Math.ceil(blockMs / 1000),
blockLevel: record.blockLevel,
}
});
return res.status(429).json({ status: 'Too many attempts. Please try again later.' });
}
return next();
};
module.exports = {
authRateLimiter,
};