Files
competition-website/app/middleware/loginRateLimit.js
WorkBuddy a303cf2d53 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*、构建/缓存产物。
2026-08-17 12:25:42 +08:00

74 lines
2.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);
};
};