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:
WorkBuddy
2026-08-17 12:25:42 +08:00
commit a303cf2d53
109 changed files with 25904 additions and 0 deletions

View 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);
};
};