Files
qubiaobiao 459f1adf6d feat(admin): R176-R180 公示表与新闻撤回 + 管理端多管理员权限(C 层)+ 交卷冻结
- R176 成绩公示表引擎(lib/publicitySheet.js + lib/assets/publicity-template.xlsx 官方模板基底)
  与指导教师回填(lib/teacherBackfill.js);赛项名称/编号改为配置项
- R177 新闻「发布 ↔ 撤回」互斥:撤回只置 is_draft=1,不删除数据
- R178 C 层 RBAC:lib/adminPermissions.js(13 权限点 / 70 条路由规则,唯一 SoT)
  + middleware/auth.js 三段判定(默认拒绝 · is_super 单点 · token_version)
  + controller/service/adminUser.js + lib/auditLog.js + model/adminAuditLog.js
- R178 B1 交卷冻结:service.exam.assertNotSubmitted(bizCode 4006 单一判据源)
- R178 B④ 提交口径拆分:formalSubmitted / submitted / answeredCount 三字段
- R178 成绩总览补小组编号:resolveTeamLabels(与选手管理同源,含兜底与三态)
- R181 生产迁移配套:admins +7 列、admin_audit_logs(scripts/migrate_r178_admin_rbac.py)
2026-10-20 11:06:29 +08:00

190 lines
8.0 KiB
JavaScript
Raw Permalink 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';
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;
}
};
};