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, };