运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。 已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
74 lines
2.5 KiB
JavaScript
74 lines
2.5 KiB
JavaScript
'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);
|
||
};
|
||
};
|