完善了安全功能

This commit is contained in:
lik
2026-06-12 15:24:20 +08:00
parent fba44ca015
commit ddcf200de2
12 changed files with 904 additions and 207 deletions

83
middleware/auth.js Normal file
View File

@@ -0,0 +1,83 @@
import { DBModel } from '../models/index.js';
import ResponseUtil from '../utils/api_response.js';
/**
* 从请求中提取 token优先 Authorization header
*/
function extractToken(ctx) {
// 优先从 Authorization: Bearer xxx 获取
const authHeader = ctx.header?.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.slice(7);
}
// 兼容旧方式body/query/header 中的 token 字段
return ctx.request.body?.token
|| ctx.request.query?.token
|| ctx.header?.token;
}
/**
* 认证中间件 - 验证 token 并挂载用户信息到 ctx
* 可选参数:
* - required: 是否必须登录(默认 true
* - roles: 允许的角色列表(可选)
*/
function auth(options = {}) {
const { required = true, roles } = options;
return async (ctx, next) => {
const token = extractToken(ctx);
if (!token) {
if (!required) {
ctx.state.user = null;
return await next();
}
return ResponseUtil.unauthorized(ctx, '缺少认证 token');
}
const user = await DBModel.User.findOne({ 'security.token': token });
if (!user) {
return ResponseUtil.unauthorized(ctx, '用户未登录或 token 无效');
}
if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) {
return ResponseUtil.unauthorized(ctx, '登录已过期,请重新登录');
}
if (user.status.account === 'lock') {
return ResponseUtil.forbidden(ctx, '账户已被锁定');
}
// 角色检查
if (roles && roles.length > 0) {
const hasRole = roles.some(role => {
// 检查 app 字段中的角色
return Object.values(user.app || {}).some(appData =>
appData && Array.isArray(appData.role) && appData.role.includes(role)
);
});
if (!hasRole) {
return ResponseUtil.forbidden(ctx, '权限不足');
}
}
ctx.state.user = user;
await next();
};
}
/**
* 返回用户安全对象(去除密码等敏感字段)
*/
function sanitizeUser(user) {
const obj = user.toObject ? user.toObject() : { ...user };
delete obj.security?.passwd;
delete obj.security?.passwdSalt;
delete obj.security?.passwordResetToken;
delete obj.security?.passwordResetExpiry;
return obj;
}
export { auth, extractToken, sanitizeUser };

51
middleware/ratelimit.js Normal file
View File

@@ -0,0 +1,51 @@
import ResponseUtil from '../utils/api_response.js';
/**
* 内存速率限制
* 基于 IP + 接口路径维度,不引入 Redis
*/
const rateLimitStore = new Map();
// 每 60 秒清理一次过期记录
const cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, record] of rateLimitStore.entries()) {
if (now - record.resetAt > record.windowMs) {
rateLimitStore.delete(key);
}
}
}, 60_000);
cleanupTimer.unref();
/**
* 创建速率限制中间件
* @param {number} max - 窗口内最大请求数
* @param {number} windowMs - 窗口时长(毫秒),默认 60 秒
*/
function rateLimit(max = 10, windowMs = 60_000) {
return async (ctx, next) => {
const ip = ctx.ip || ctx.request.ip;
const key = `${ip}:${ctx.path}`;
const now = Date.now();
let record = rateLimitStore.get(key);
if (!record || now - record.resetAt > windowMs) {
record = { count: 0, resetAt: now, windowMs };
rateLimitStore.set(key, record);
}
record.count++;
if (record.count > max) {
const retryAfter = Math.ceil((record.resetAt + windowMs - now) / 1000);
ctx.set('Retry-After', retryAfter);
return ResponseUtil.error(ctx, `请求过于频繁,请 ${retryAfter} 秒后重试`, null, 429);
}
ctx.set('X-RateLimit-Limit', max);
ctx.set('X-RateLimit-Remaining', Math.max(0, max - record.count));
await next();
};
}
export { rateLimit };