117 lines
4.1 KiB
JavaScript
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,
|
|
};
|