chore: 后端首次基线提交
运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。 已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
This commit is contained in:
73
app/middleware/loginRateLimit.js
Normal file
73
app/middleware/loginRateLimit.js
Normal file
@@ -0,0 +1,73 @@
|
||||
'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);
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user