Files
competition-website/app/middleware/auth.js

190 lines
8.0 KiB
JavaScript
Raw Normal View History

'use strict';
const jwt = require('jsonwebtoken');
// R178:管理端权限判定(单一真相源,见 lib/adminPermissions.js)
const adminPermissions = require('../lib/adminPermissions');
/**
* JWT 认证中间件
*
* 功能:
* 1. 从请求头 Authorization 中提取 Bearer Token
* 2. 验证 JWT 的有效性
* 3. 将解码后的用户信息挂载到 ctx.state.currentUser
* 4. 支持管理员角色校验(admin 路由专用)
* 5. 验证失败返回统一错误响应
*
* 使用方式:
* - 选手认证:app.middleware.auth()
* - 管理员认证:app.middleware.auth({ adminOnly: true })
*
* ctx.state.currentUser 数据结构:
* {
* uid: 账号 uid,
* id_number: 身份证号,
* role: 'contestant' | 'admin'
* }
*/
module.exports = (options = {}, app) => {
return async function authMiddleware(ctx, next) {
// 从 ctx.app.config 中获取 JWT 密钥
const appConfig = app || ctx.app;
const { secret } = appConfig.config.jwt;
// 获取 Authorization 请求头;同时支持 Query ?token= 回退(用于跨设备直链下载,
// 如 iOS Safari 不响应 blob + a.download 时改用浏览器原生导航下载的场景)。
const authorization = ctx.get('Authorization') ||
(ctx.query && ctx.query.token ? `Bearer ${ctx.query.token}` : '');
// 检查 Authorization 头是否存在
if (!authorization) {
ctx.status = 401;
ctx.body = {
code: 401,
message: '未提供认证令牌,请先登录',
};
return;
}
// 提取 Bearer Token(格式:Bearer <token>)
const tokenParts = authorization.split(' ');
if (tokenParts.length !== 2 || tokenParts[0] !== 'Bearer') {
ctx.status = 401;
ctx.body = {
code: 401,
message: '认证令牌格式错误,应为 Bearer <token>',
};
return;
}
const token = tokenParts[1];
// 验证 JWT Token
try {
const decoded = jwt.verify(token, secret);
// 将解码后的用户信息挂载到 ctx.state.currentUser
ctx.state.currentUser = {
id: decoded.id || decoded.uid || 0,
uid: decoded.uid || decoded.id || 0,
id_number: decoded.id_number || '',
phone: decoded.phone || '',
role: decoded.role,
};
// 如果启用了管理员模式,校验当前用户是否为管理员
if (options.adminOnly) {
if (ctx.state.currentUser.role !== 'admin') {
ctx.status = 403;
ctx.body = {
code: 403,
message: '无权限访问,需要管理员权限',
};
return;
}
// ==================== R178 多管理员 + 权限(RBAC) ====================
//
// 🔴 为什么挂在这里,而不是新写一个全局中间件:
// Egg 的全局中间件早于 router 级中间件执行,此时 ctx.state.currentUser 还不存在;
// 若改成「逐条路由追加 adminPermission」,63 条 /api/admin 路由每一条都要改一次
// —— 漏一条就是后门,且新增路由默认无保护。
// adminOnly 分支已经挂在**每一条**管理端路由上,是唯一能保证零遗漏的位置。
//
// 🔴 为什么每次请求都查库(而不是把权限塞进 JWT):
// 权限进 JWT 后,改权限 / 禁用 / 重置口令在令牌过期前都不生效(本项目令牌 7 天)。
// 禁用某人却要等 7 天,等于没有禁用。单行主键查询的成本可以忽略。
//
// 三道判定(顺序不可换,否则错误信息会互相掩盖):
// 1) 账号仍存在 —— 被删除 ⇒ 401
// 2) status 启用 + 令牌版本一致 —— 被禁用 ⇒ 401;改密/禁用后旧令牌 ⇒ 401
// (⚠️ 两处都用 401 而非 403:账号停用/口令变更意味着「认证主体不再被接受」,
// 前端应回登录页;403 会被误解成「权限不足」。)
// 3) 路由已登记 + 权限命中 —— 未登记 ⇒ 403(默认拒绝,不是放行)
const admin = await ctx.model.Admin.findOne({
where: { id: ctx.state.currentUser.id },
attributes: ['id', 'username', 'is_super', 'permissions', 'status', 'token_version'],
raw: true,
});
if (!admin) {
ctx.status = 401;
ctx.body = { code: 401, message: '管理员账号不存在或已被删除,请重新登录' };
return;
}
if (Number(admin.status) !== 1) {
// 🔴 用 401 而不是 403:账号被停用意味着「认证主体不再被接受」,
// 前端应把用户送回登录页。若返回 403,前端会理解为「权限不够」,
// 提示「无权限执行此操作」,与实际原因(账号已停用)不符。
ctx.status = 401;
ctx.body = { code: 401, message: '该管理员账号已被禁用,请联系最高管理员' };
return;
}
// 令牌版本:改密 / 禁用 / 改权限时由服务层自增,旧令牌立即失效。
// 存量令牌无 tv 字段 ⇒ 视作 0,与初值一致,避免升级瞬间把在线的管理员全部踢下线。
const tokenVersion = Number(decoded.tv || 0);
if (Number(admin.token_version) !== tokenVersion) {
ctx.status = 401;
ctx.body = { code: 401, message: '登录状态已失效(口令或权限已变更),请重新登录' };
return;
}
admin.permissions = adminPermissions.parsePermissions(admin.permissions);
ctx.state.currentAdmin = admin;
const required = adminPermissions.resolvePermission(ctx.method, ctx.path);
if (required === null) {
// 未登记 = 配置缺陷。默认拒绝并**大声记日志**,否则新路由会静默变成
// 「任何管理员都能调」的后门,而要等到出事才会被发现。
ctx.logger.error(
`[RBAC] 未登记权限策略的管理端路由: ${ctx.method} ${ctx.path} —— 已按默认拒绝处理,请补 lib/adminPermissions.js 的 ROUTE_TABLE`
);
ctx.status = 403;
ctx.body = {
code: 403,
message: '该接口未登记权限策略,已拒绝访问(请联系系统管理员)',
};
return;
}
if (!adminPermissions.canAccess(admin, required)) {
ctx.logger.warn(
`[RBAC] 拒绝访问 admin=${admin.username}(${admin.id}) ${ctx.method} ${ctx.path} 需要=${required.join('|')}`
);
ctx.status = 403;
ctx.body = { code: 403, message: '无权限执行此操作,请联系最高管理员分配权限' };
return;
}
}
// 继续执行后续中间件和控制器
await next();
} catch (err) {
// 仅当错误确实是 JWT 校验问题(过期/签名无效)时,才返回 401「认证失败」。
// 注意:try 内部包含 `await next()`,即后续中间件与控制器也在同一 try 中。
// 若下游控制器/服务抛出异常(如文件读取失败、数据库异常等),绝不能被
// 这里误吞并伪装成 401,否则前端会误判「登录过期」把用户踢回登录页。
// 因此:非 JWT 类错误必须重新抛出,交由 Egg 全局错误处理(返回 500 并写日志)。
if (err.name === 'TokenExpiredError') {
// Token 已过期
ctx.status = 401;
ctx.body = {
code: 401,
message: '认证令牌已过期,请重新登录',
};
return;
}
if (err.name === 'JsonWebTokenError') {
// Token 无效(签名错误、格式错误等)
ctx.status = 401;
ctx.body = {
code: 401,
message: '无效的认证令牌',
};
return;
}
// 其它异常(下游业务逻辑抛出):原样抛出,避免被误报为 401
throw err;
}
};
};