fix(auth): add account+IP brute-force rate limiting
This commit is contained in:
116
middleware/authRateLimiter.js
Normal file
116
middleware/authRateLimiter.js
Normal file
@@ -0,0 +1,116 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user