'use strict'; /** * 登录限流中间件 - loginRateLimit * * 面向 /api/auth/login 与 /api/auth/admin-login,防止暴力破解。 * * 策略: * - 以「客户端 IP + 登录标识(phone/username)」为维度,在滑动窗口内统计「失败」次数。 * - 达到阈值后,在窗口结束前拒绝该维度的后续登录尝试(返回业务错误码 4290)。 * - 登录成功(响应 code === 0)立即清空该维度的失败计数。 * * 说明:采用进程内内存计数(Map),零外部依赖、不影响本地/无 Redis 环境。 * 多进程(egg-cluster)下各 worker 各自计数,属于可接受的基线防护; * 如需全局精确限流,可后续替换为基于 Redis 的实现。 * * @param {object} options - 配置项 * @param {number} [options.windowMs=900000] - 统计窗口(毫秒),默认 15 分钟 * @param {number} [options.maxAttempts=10] - 窗口内允许的最大失败次数 * @return {Function} Egg 中间件 */ module.exports = (options = {}) => { const windowMs = options.windowMs || 15 * 60 * 1000; const maxAttempts = options.maxAttempts || 10; // key -> { count, first } const store = new Map(); const cleanup = (now) => { for (const [ k, v ] of store) { if (now - v.first >= windowMs) store.delete(k); } }; return async function loginRateLimit(ctx, next) { const body = ctx.request.body || {}; const identifier = body.phone || body.username || 'unknown'; const key = `${ctx.ip}|${identifier}`; const now = Date.now(); let rec = store.get(key); // 窗口已过期则视为无记录 if (rec && now - rec.first >= windowMs) { store.delete(key); rec = undefined; } // 已超过阈值:直接拒绝 if (rec && rec.count >= maxAttempts) { const remainMin = Math.ceil((windowMs - (now - rec.first)) / 60000); ctx.logger.warn(`[登录限流] 拒绝来自 ${key} 的登录尝试(已失败 ${rec.count} 次)`); ctx.body = ctx.helper.error(4290, `登录尝试过于频繁,请约 ${remainMin} 分钟后再试`); return; } await next(); // 依据响应判定成败:统一响应体 { code: 0 } 为成功 const succeeded = ctx.body && ctx.body.code === 0; if (succeeded) { store.delete(key); } else { if (rec) { rec.count += 1; } else { store.set(key, { count: 1, first: now }); } } // 惰性清理,避免 Map 无限增长 if (store.size > 5000) cleanup(now); }; };