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

653 lines
25 KiB
JavaScript
Raw Normal View History

'use strict';
const Controller = require('egg').Controller;
const svgCaptcha = require('svg-captcha');
const VALIDATION_RULES = require('../validate');
// 口令强度策略(唯一事实来源):黑名单 + 纯重复/序列 + 账号派生值 + 长度上下限。
// ⚠️ 任何新增的口令写入入口都必须调用它,禁止在控制器内自行判断(见 lib/passwordPolicy.js 模块头)。
const passwordPolicy = require('../lib/passwordPolicy');
/**
* 认证控制器 - AuthController
*
* 负责选手和管理员的登录认证、账号激活、验证码、密码重置等功能。
* 使用 Contestant 模型进行选手认证,Admin 模型进行管理员认证。
* 密码采用 bcrypt 加盐哈希存储;登录时兼容存量 SHA256 旧哈希并在验证成功后透明升级为 bcrypt。
* 统一响应格式:ctx.helper.success(data, msg) / ctx.helper.error(code, msg)
*
* 主要接口:
* - POST /api/auth/captcha : 获取图形验证码(SVG)
* - POST /api/auth/sms : 发送短信验证码(模拟)
* - POST /api/auth/activate : 激活账号(设置密码)
* - POST /api/auth/login : 选手登录
* - POST /api/auth/admin-login : 管理员登录
* - GET /api/auth/current-user : 获取当前用户信息(需认证)
* - POST /api/auth/forgot-password : 忘记密码 - 发送验证码
* - POST /api/auth/reset-password : 重置密码
* - POST /api/auth/logout : 退出登录(需认证)
*/
class AuthController extends Controller {
/**
* 交付短信验证码(统一入口,供 sms / forgotPassword 复用)
*
* 行为由运行模式(config.isProduction,来自环境变量 APP_ENV)驱动,
* 使测试版本与生产版本代码路径完全一致、仅差异于配置:
* - 生产环境 + 网关已开启(config.sms.realSend):通过阿里云真实下发,
* 验证码绝不写入 HTTP 响应体、不打印到控制台(防凭身份证号接管账号)。
* - 测试环境(APP_ENV=test,或缺省):【不真实发送】验证码明文随响应返回
* (字段 smsCode),由前端弹窗展示,便于测试人员直接获取,无需配置短信网关。
* - 生产环境 + 网关未开启:安全起见不下发、不回传,仅记录告警(需运维配置 SMS_ENABLED=1)。
*
* @param {object} contestant - 选手对象(含 phone)
* @param {string} smsCode - 6 位验证码
* @param {string} sceneLabel - 场景标签(如 '账号激活' / '忘记密码')
* @return {boolean} 是否允许在响应体中回传验证码(仅测试环境为 true)
*/
async _deliverSmsCode(contestant, smsCode, sceneLabel) {
const { ctx, app } = this;
const sms = app.config.sms || {};
const isProduction = app.config.isProduction;
// 服务端审计日志(仅落日志文件,不含验证码明文回传前端)
ctx.logger.info(
`[短信验证码] 场景=${sceneLabel} 手机号=${contestant.phone} 环境=${app.config.runMode} realSend=${!!sms.realSend}`
);
// 生产环境 + 网关已开启 → 真实下发,绝不回传验证码明文
if (isProduction && sms.realSend) {
try {
await ctx.service.sms.sendVerifyCode(contestant.phone, smsCode);
ctx.logger.info(`[短信验证码] 场景=${sceneLabel} 已通过网关下发至 ${contestant.phone}`);
} catch (e) {
// 下发失败:记录告警;验证码已存入 session,用户可重试获取,绝不在响应回传
ctx.logger.error(`[短信验证码] 场景=${sceneLabel} 网关下发失败(${contestant.phone}):${e && e.message}`);
}
return false;
}
if (isProduction) {
// 生产环境未开启网关:为避免账号接管风险,绝不回传或打印验证码,仅记录告警
ctx.logger.warn('[短信验证码] 生产环境未启用短信网关(realSend=false),验证码未下发。请设置 SMS_ENABLED=1 并配置阿里云密钥。');
return false;
}
// 测试环境:不真实发送,回传验证码供前端弹窗显示(控制台同步打印便于联调)
console.log('');
console.log('╔══════════════════════════════════════════════╗');
console.log(`║ 📱 [测试环境] 模拟短信验证码 - ${sceneLabel}`);
console.log('╠══════════════════════════════════════════════╣');
console.log(`║ 手机号 : ${contestant.phone}`);
console.log(`║ 验证码 : ${smsCode}`);
console.log('║ 有效期 : 5 分钟');
console.log('╚══════════════════════════════════════════════╝');
console.log('');
return true;
}
/**
* 获取点击式验证码(按顺序点击文字)
*
* 生成 4 个随机中文汉字,指定点击顺序,返回打乱后的文字和提示。
* 用户需按照提示顺序依次点击文字,点击序列存入 session 用于校验。
*/
async captcha() {
const { ctx } = this;
// 字符池(常用中文字符,避免生僻字)
const charPool = ['天', '地', '人', '和', '山', '水', '风', '火', '日', '月', '星', '辰', '花', '鸟', '云', '雨'];
// 随机选取 4 个不重复字符
const pool = [...charPool];
const selected = [];
for (let i = 0; i < 4; i++) {
const idx = Math.floor(Math.random() * pool.length);
selected.push(pool.splice(idx, 1)[0]);
}
// 存入 session:点击顺序的字符串(如 "天地人和")
const clickOrder = selected.join('');
ctx.session.captcha = clickOrder;
// 打乱显示顺序
const displayOrder = [...selected].sort(() => Math.random() - 0.5);
ctx.body = ctx.helper.success({
chars: displayOrder,
hint: '请依次点击:' + selected.join(' → '),
}, '验证码获取成功');
}
/**
* 发送短信验证码(模拟)
*
* 前端传入身份证号作为 phone 参数,后端根据身份证号查找选手的真实手机号。
*
* 流程:
* 1. 校验图形验证码
* 2. 通过身份证号查找参赛选手
* 3. 生成 6 位随机数字验证码
* 4. 存入 ctx.session.smsCode / smsPhone / smsIdNumber
* 5. 返回真实手机号、姓名、组别和模拟验证码
*/
async sms() {
const { ctx, service } = this;
const { phone, captcha } = ctx.request.body;
// 统一输入校验(ctx.validate)
try {
ctx.validate(VALIDATION_RULES.sms, ctx.request.body);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
// 校验图形验证码
if (!ctx.session.captcha || captcha !== ctx.session.captcha) {
ctx.body = ctx.helper.error(1002, '图形验证码错误');
return;
}
// 通过身份证号查找选手
const contestant = await service.auth.findByAccount(phone);
if (!contestant) {
ctx.body = ctx.helper.error(1003, '该身份证号不在参赛名单中,请联系管理员');
return;
}
// 检查是否已激活
if (contestant.activated) {
ctx.body = ctx.helper.error(1004, '该账号已激活,请直接登录');
return;
}
// 生成 6 位随机数字验证码
const smsCode = ctx.helper.randomCode(6);
// 存入 session(5 分钟有效期)
ctx.session.smsCode = smsCode;
ctx.session.smsPhone = contestant.phone;
ctx.session.smsIdNumber = phone;
ctx.session.smsCodeExpire = Date.now() + 5 * 60 * 1000;
// 清除已使用的图形验证码
ctx.session.captcha = null;
// 交付验证码(生产环境不回传/不打印,仅走真实网关)
const exposeCode = await this._deliverSmsCode(contestant, smsCode, '账号激活');
const respData = {
phone: contestant.phone,
name: contestant.name,
group: contestant.group,
};
if (exposeCode) respData.smsCode = smsCode;
ctx.body = ctx.helper.success(respData, '短信验证码发送成功');
}
/**
* 激活账号
*
* 流程:
* 1. 校验身份证号、手机号、短信验证码
* 2. 通过身份证号查找选手
* 3. 检查账号是否已激活
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
* 4. bcrypt 加盐哈希密码(BCRYPT_ROUNDS=10,见 extend/helper.js),更新 activated=1
* 5. 清除 session 中的短信验证码
*/
async activate() {
const { ctx, service } = this;
const { id_number, phone, smsCode, password } = ctx.request.body;
// 统一输入校验(ctx.validate)
try {
ctx.validate(VALIDATION_RULES.activate, ctx.request.body);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
// 口令强度校验(R63 · C1)
// 背景:此前仅由 validate 校验「长度 ≥ 6」,`123456` 完全合法 —— R61 测试人员
// 实测「浏览器密码管理器预填 123456,直接提交后即成为账号口令」。
// 位置(validate 之后 / 短信码之前)的理由:
// · validate 只管「必填 + 格式」,强度是业务规则,分层后两者失败文案互不覆盖;
// · 纯本地计算、无副作用,可快速失败,不消耗也不依赖 session 中的验证码。
// 错误码 2001 = 口令强度不足(activate 段编码;resetPassword 段用 7001)。
// ⚠️ 日志只记命中的规则名,绝不记录口令明文或身份证号。
const pwdCheck = passwordPolicy.checkPassword(password, { idNumber: id_number, phone });
if (!pwdCheck.ok) {
ctx.logger.info(`[口令策略] 激活口令被拒:rule=${pwdCheck.rule}`);
ctx.body = ctx.helper.error(2001, pwdCheck.reason);
return;
}
// 校验短信验证码
if (!ctx.session.smsCode || smsCode !== ctx.session.smsCode) {
ctx.body = ctx.helper.error(2005, '短信验证码错误');
return;
}
// 校验短信验证码是否过期
if (ctx.session.smsCodeExpire && Date.now() > ctx.session.smsCodeExpire) {
ctx.body = ctx.helper.error(2006, '短信验证码已过期');
return;
}
// 校验手机号一致性
if (ctx.session.smsPhone !== phone && ctx.session.smsIdNumber !== id_number) {
ctx.body = ctx.helper.error(2007, '手机号与发送验证码时的手机号不一致');
return;
}
// 通过身份证号查找选手
const contestant = await service.auth.findByAccount(id_number);
if (!contestant) {
ctx.body = ctx.helper.error(2008, '该身份证号不在参赛名单中,请联系管理员');
return;
}
// 检查是否已激活
if (contestant.activated) {
ctx.body = ctx.helper.error(2009, '该账号已激活,请直接登录');
return;
}
// bcrypt 加盐哈希密码
const hashedPassword = await ctx.helper.hashPassword(password);
// 更新选手:激活状态 + 密码
await service.auth.activateAccount(contestant.id, hashedPassword);
// 清除 session 中的短信验证码
ctx.session.smsCode = null;
ctx.session.smsPhone = null;
ctx.session.smsIdNumber = null;
ctx.session.smsCodeExpire = null;
ctx.body = ctx.helper.success({
id: contestant.id,
id_number: contestant.id_number,
name: contestant.name,
group: contestant.group,
}, '账号激活成功');
}
/**
* 选手登录 - 使用手机号
*
* 流程:
* 1. 通过手机号查找选手
* 2. 检查账号是否激活
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
* 3. bcrypt 比对密码(helper.comparePassword;兼容存量 SHA256 旧哈希,
* 并在验证成功后透明升级为 bcrypt)
* 4. 生成 JWT Token,返回用户信息和 Token
*/
async login() {
const { ctx, service } = this;
const { phone, password } = ctx.request.body;
// 统一输入校验(ctx.validate)
try {
ctx.validate(VALIDATION_RULES.login, ctx.request.body);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
// 查找选手
const contestant = await service.auth.findByPhone(phone);
if (!contestant) {
ctx.body = ctx.helper.error(3003, '该手机号不在参赛名单中');
return;
}
// 检查是否激活
if (!contestant.activated) {
ctx.body = ctx.helper.error(3004, '账号未激活,请先激活');
return;
}
// 验证密码(兼容 bcrypt 与存量 SHA256)
const isPasswordValid = await service.auth.verifyPassword(password, contestant.password);
if (!isPasswordValid) {
ctx.body = ctx.helper.error(3005, '密码错误');
return;
}
// 若为旧版 SHA256 哈希,登录成功后透明升级为 bcrypt
await service.auth.upgradePasswordIfLegacy(contestant, password);
// 生成 JWT Token
const token = ctx.helper.generateToken({
id: contestant.id,
phone: contestant.phone,
role: 'contestant',
}, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
feat(big-question): 中职/高职组「综合大题」在线作答 + 封堵 auth 成绩泄露三通道 ## 一、需求变更:中职/高职组 Word 文件上传题 → 在线「综合大题」 按 2026-09-21 需求变更,中职组 / 高职组取消 Word 文件上传题,改为在线作答 综合大题(1 道大题 × 3 个小问 × 每小问 10 分 = 大题 30 分)。 - 新增表(迁移 023/024) · `big_questions` —— 题库(题干富文本含表格 + 三小问提示语 + 评分口径) · `big_question_answers` —— 作答(抽题快照 + 三小问答案 + 按小问得分) 两份迁移同时提供 `.sql`(生产直跑)与 `.js`(本地/CI 幂等执行)。 - 新增 model:`app/model/bigQuestion.js`、`app/model/bigQuestionAnswer.js` - 新增 service:`app/service/bigQuestion.js`(抽题 / 保存答案 / 评分核心逻辑) - 考生端接口(`app/controller/exam.js`) · `GET /api/exam/:group/big-question` —— 首次抽题并落库题干快照, 后续一律返回同一道题(**幂等**,防止同一考生拿到不同题干引发纠纷) · `POST /api/exam/:group/big-answer` —— 保存三小问答案,带考试窗口门控 - 管理端接口(`app/controller/admin.js`) · 题库 CRUD:`/api/admin/big-questions/:group[/:id]` · 作答查证:`GET /api/admin/big-answers/:group`(考生 → 抽到的题 → 三小问 → 提交时间) · 按小问评分:`PUT /api/admin/big-answers/:group/:uid/scores` - 服务端封堵越界数据:`app/controller/exam.js` 对中职/高职组的 Word 上传接口 一并拒绝(前端入口已移除,但直调 API 仍可绕过)。 **所有新增读写仅落在新增的两张表,不触碰任何既有表结构。** ## 二、安全修复(铁律 15 成绩泄露 —— auth 通道缺口) 原有成绩发布门控只覆盖 4 条通道,遗漏了 auth 的**三条同源通道**:三处都直接 回传 `toJSON()`,在 `score_published_<group> = false`(未发布)时照样下发成绩, 而同期 `GET /api/score` 正确返回 `null`。 - `POST /api/auth/login` —— 实测未发布态回传 `score` - `GET /api/auth/current-user` —— 全字段 `toJSON`,连带下发分项 - `POST /api/auth/refresh` —— 全字段 `toJSON`,且前端 `doSilentRefresh()` 会把响应 `user` 写入 `localStorage` 与 Pinia,等效于登录通道; 实测泄漏 `score` + `exam_score` + `word_score` + `ppt_score` 共 4 个字段 修复方式:新增 `AuthService.toClientUser()` 作为**唯一出口**,三处全部收敛, 禁止各自直接 `toJSON`。口径: - 该组已发布 → 保留 `score`;未发布 / 查询异常 → 剔除 `score`(异常按未发布,安全默认) - `exam_score` / `word_score` / `ppt_score` **任何情况下都不下发** (前端不使用;保留会形成"总分虽隐藏、分项可加总"的旁路) 门禁 `scripts/verify_score_leak_all_channels.py` 相应从 14 项扩到 20 项, 新增通道⑤/⑥/⑦断言(含"发布后 score 必须恢复"的**正向对照**,防止 "永不回传"式过度封堵造成假绿),并经变异注入自检证明断言可证伪。 ## 三、移除已弃用的 Word 模板 中职 / 高职各 3 个历史 Word 模板(`app/public/templates/{zhongzhi,gaozhi}/*.docx`) 随需求一并移除;目录保留 `.gitkeep` 以维持路径存在。 --- 验证:G10 铁律15 门禁 20/20 PASS(含变异自检,EXIT=0)、G9 铁律14 门禁 22/22 PASS、 窗口哨兵残留自检 6/6 PASS。前端配套改动见前端仓库提交 1bfe007。
2026-09-21 11:57:58 +08:00
// 返回用户信息 —— 统一走 AuthService.toClientUser 出口:
// 剔除密码 + 按「成绩发布门控」决定是否携带 score(铁律15 全通道封堵,2026-09-21)
const userData = await service.auth.toClientUser(contestant);
ctx.body = ctx.helper.success({
token,
user: userData,
}, '登录成功');
}
/**
* 管理员登录
*
* 流程:
* 1. 通过用户名从 admins 表查找管理员
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
* 2. bcrypt 比对密码(兼容存量 SHA256 旧哈希)
* 3. 生成管理员 JWT Token
*/
async adminLogin() {
const { ctx, service } = this;
const { username, password } = ctx.request.body;
// 统一输入校验(ctx.validate)
try {
ctx.validate(VALIDATION_RULES.adminLogin, ctx.request.body);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
const admin = await service.auth.findAdmin(username);
if (!admin) {
ctx.body = ctx.helper.error(4003, '管理员不存在');
return;
}
// 管理员密码比对(兼容 bcrypt 与存量 SHA256)
const isValid = await service.auth.verifyPassword(password, admin.password);
if (!isValid) {
ctx.body = ctx.helper.error(4004, '密码错误');
return;
}
2026-10-14 10:47:51 +08:00
// P2-3 默认口令守卫:内置默认管理员(见 database/schema.sql 与 database/seeders/002-seed-admin.js),
// 生产环境禁止使用默认口令登录,防止默认账号直接上线被接管;
// 非生产环境放行但记录告警,并在响应中提示尽快改密。
2026-10-14 10:47:51 +08:00
//
// ⚠️ 【R111 更正 · 实测】原注释写「schema.sql 内置默认管理员(admin/admin123)」,两处失实:
// ① 归属错 —— `admin123` 是 **seeder 002** 的 bcrypt 值;`schema.sql:214` 的字面哈希实测 = SHA256(`admin`)。
// ② 列表漏项 —— 原列表 `['admin123','admin123456']` **不含 `admin`**,而 `schema.sql` 建库路径的初始
// 口令恰为 `admin` ⇒ 生产若未改密,用 `admin/admin` 登录**不会**触发 4005(`verifyPassword` 兼容
// SHA256 ⇒ 可直接登录成功)⇒ 恰是本守卫要防的场景却不设防。已补入 `'admin'`。
// 详见 `.workbuddy/memory/BACKLOG.md` 候选⑱/㉑。
// ⚠️ 结构性缺口**未消除**:本守卫是「口令字面」判据 ⇒ 任何未枚举的默认口令均绕过;
// 彻底治法应改为「哈希值判据」(DB 值 ∈ 已知默认哈希集)。见候选㉑。
const DEFAULT_ADMIN_PASSWORDS = ['admin', 'admin123', 'admin123456'];
const usingDefaultPassword = DEFAULT_ADMIN_PASSWORDS.includes(password);
const isProd = this.app.config.isProduction;
if (usingDefaultPassword && isProd) {
ctx.logger.error(`[安全阻断] 管理员 ${admin.username} 在生产环境尝试使用默认密码登录,已拒绝`);
ctx.body = ctx.helper.error(4005, '生产环境禁止使用默认密码登录,请联系运维在数据库中重置为强密码后再试');
return;
}
if (usingDefaultPassword) {
ctx.logger.warn(`[安全提醒] 管理员 ${admin.username} 正在使用默认密码,请尽快修改`);
}
// 若为旧版 SHA256 哈希,登录成功后透明升级为 bcrypt
await service.auth.upgradePasswordIfLegacy(admin, password);
const token = ctx.helper.generateToken({
id: admin.id,
role: 'admin',
}, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
ctx.body = ctx.helper.success({
token,
user: {
id: admin.id,
username: admin.username,
role: 'admin',
},
// 使用默认口令时向前端透出改密提醒标记
mustChangePassword: usingDefaultPassword,
}, '管理员登录成功');
}
/**
* 获取当前登录用户信息(需认证)
*/
async currentUser() {
feat(big-question): 中职/高职组「综合大题」在线作答 + 封堵 auth 成绩泄露三通道 ## 一、需求变更:中职/高职组 Word 文件上传题 → 在线「综合大题」 按 2026-09-21 需求变更,中职组 / 高职组取消 Word 文件上传题,改为在线作答 综合大题(1 道大题 × 3 个小问 × 每小问 10 分 = 大题 30 分)。 - 新增表(迁移 023/024) · `big_questions` —— 题库(题干富文本含表格 + 三小问提示语 + 评分口径) · `big_question_answers` —— 作答(抽题快照 + 三小问答案 + 按小问得分) 两份迁移同时提供 `.sql`(生产直跑)与 `.js`(本地/CI 幂等执行)。 - 新增 model:`app/model/bigQuestion.js`、`app/model/bigQuestionAnswer.js` - 新增 service:`app/service/bigQuestion.js`(抽题 / 保存答案 / 评分核心逻辑) - 考生端接口(`app/controller/exam.js`) · `GET /api/exam/:group/big-question` —— 首次抽题并落库题干快照, 后续一律返回同一道题(**幂等**,防止同一考生拿到不同题干引发纠纷) · `POST /api/exam/:group/big-answer` —— 保存三小问答案,带考试窗口门控 - 管理端接口(`app/controller/admin.js`) · 题库 CRUD:`/api/admin/big-questions/:group[/:id]` · 作答查证:`GET /api/admin/big-answers/:group`(考生 → 抽到的题 → 三小问 → 提交时间) · 按小问评分:`PUT /api/admin/big-answers/:group/:uid/scores` - 服务端封堵越界数据:`app/controller/exam.js` 对中职/高职组的 Word 上传接口 一并拒绝(前端入口已移除,但直调 API 仍可绕过)。 **所有新增读写仅落在新增的两张表,不触碰任何既有表结构。** ## 二、安全修复(铁律 15 成绩泄露 —— auth 通道缺口) 原有成绩发布门控只覆盖 4 条通道,遗漏了 auth 的**三条同源通道**:三处都直接 回传 `toJSON()`,在 `score_published_<group> = false`(未发布)时照样下发成绩, 而同期 `GET /api/score` 正确返回 `null`。 - `POST /api/auth/login` —— 实测未发布态回传 `score` - `GET /api/auth/current-user` —— 全字段 `toJSON`,连带下发分项 - `POST /api/auth/refresh` —— 全字段 `toJSON`,且前端 `doSilentRefresh()` 会把响应 `user` 写入 `localStorage` 与 Pinia,等效于登录通道; 实测泄漏 `score` + `exam_score` + `word_score` + `ppt_score` 共 4 个字段 修复方式:新增 `AuthService.toClientUser()` 作为**唯一出口**,三处全部收敛, 禁止各自直接 `toJSON`。口径: - 该组已发布 → 保留 `score`;未发布 / 查询异常 → 剔除 `score`(异常按未发布,安全默认) - `exam_score` / `word_score` / `ppt_score` **任何情况下都不下发** (前端不使用;保留会形成"总分虽隐藏、分项可加总"的旁路) 门禁 `scripts/verify_score_leak_all_channels.py` 相应从 14 项扩到 20 项, 新增通道⑤/⑥/⑦断言(含"发布后 score 必须恢复"的**正向对照**,防止 "永不回传"式过度封堵造成假绿),并经变异注入自检证明断言可证伪。 ## 三、移除已弃用的 Word 模板 中职 / 高职各 3 个历史 Word 模板(`app/public/templates/{zhongzhi,gaozhi}/*.docx`) 随需求一并移除;目录保留 `.gitkeep` 以维持路径存在。 --- 验证:G10 铁律15 门禁 20/20 PASS(含变异自检,EXIT=0)、G9 铁律14 门禁 22/22 PASS、 窗口哨兵残留自检 6/6 PASS。前端配套改动见前端仓库提交 1bfe007。
2026-09-21 11:57:58 +08:00
const { ctx, service } = this;
// 从 JWT token 中获取用户信息
const token = ctx.headers.authorization
? ctx.headers.authorization.replace('Bearer ', '')
: null;
if (!token) {
ctx.body = ctx.helper.error(5001, '未登录');
return;
}
let decoded;
try {
decoded = ctx.helper.verifyToken(token, this.app.config.jwt.secret);
} catch (e) {
ctx.body = ctx.helper.error(5002, 'Token 已过期,请重新登录');
return;
}
if (!decoded || !decoded.id) {
ctx.body = ctx.helper.error(5003, 'Token 无效');
return;
}
const contestant = await ctx.model.Contestant.findOne({
where: { id: decoded.id },
});
if (!contestant) {
ctx.body = ctx.helper.error(5001, '用户不存在');
return;
}
feat(big-question): 中职/高职组「综合大题」在线作答 + 封堵 auth 成绩泄露三通道 ## 一、需求变更:中职/高职组 Word 文件上传题 → 在线「综合大题」 按 2026-09-21 需求变更,中职组 / 高职组取消 Word 文件上传题,改为在线作答 综合大题(1 道大题 × 3 个小问 × 每小问 10 分 = 大题 30 分)。 - 新增表(迁移 023/024) · `big_questions` —— 题库(题干富文本含表格 + 三小问提示语 + 评分口径) · `big_question_answers` —— 作答(抽题快照 + 三小问答案 + 按小问得分) 两份迁移同时提供 `.sql`(生产直跑)与 `.js`(本地/CI 幂等执行)。 - 新增 model:`app/model/bigQuestion.js`、`app/model/bigQuestionAnswer.js` - 新增 service:`app/service/bigQuestion.js`(抽题 / 保存答案 / 评分核心逻辑) - 考生端接口(`app/controller/exam.js`) · `GET /api/exam/:group/big-question` —— 首次抽题并落库题干快照, 后续一律返回同一道题(**幂等**,防止同一考生拿到不同题干引发纠纷) · `POST /api/exam/:group/big-answer` —— 保存三小问答案,带考试窗口门控 - 管理端接口(`app/controller/admin.js`) · 题库 CRUD:`/api/admin/big-questions/:group[/:id]` · 作答查证:`GET /api/admin/big-answers/:group`(考生 → 抽到的题 → 三小问 → 提交时间) · 按小问评分:`PUT /api/admin/big-answers/:group/:uid/scores` - 服务端封堵越界数据:`app/controller/exam.js` 对中职/高职组的 Word 上传接口 一并拒绝(前端入口已移除,但直调 API 仍可绕过)。 **所有新增读写仅落在新增的两张表,不触碰任何既有表结构。** ## 二、安全修复(铁律 15 成绩泄露 —— auth 通道缺口) 原有成绩发布门控只覆盖 4 条通道,遗漏了 auth 的**三条同源通道**:三处都直接 回传 `toJSON()`,在 `score_published_<group> = false`(未发布)时照样下发成绩, 而同期 `GET /api/score` 正确返回 `null`。 - `POST /api/auth/login` —— 实测未发布态回传 `score` - `GET /api/auth/current-user` —— 全字段 `toJSON`,连带下发分项 - `POST /api/auth/refresh` —— 全字段 `toJSON`,且前端 `doSilentRefresh()` 会把响应 `user` 写入 `localStorage` 与 Pinia,等效于登录通道; 实测泄漏 `score` + `exam_score` + `word_score` + `ppt_score` 共 4 个字段 修复方式:新增 `AuthService.toClientUser()` 作为**唯一出口**,三处全部收敛, 禁止各自直接 `toJSON`。口径: - 该组已发布 → 保留 `score`;未发布 / 查询异常 → 剔除 `score`(异常按未发布,安全默认) - `exam_score` / `word_score` / `ppt_score` **任何情况下都不下发** (前端不使用;保留会形成"总分虽隐藏、分项可加总"的旁路) 门禁 `scripts/verify_score_leak_all_channels.py` 相应从 14 项扩到 20 项, 新增通道⑤/⑥/⑦断言(含"发布后 score 必须恢复"的**正向对照**,防止 "永不回传"式过度封堵造成假绿),并经变异注入自检证明断言可证伪。 ## 三、移除已弃用的 Word 模板 中职 / 高职各 3 个历史 Word 模板(`app/public/templates/{zhongzhi,gaozhi}/*.docx`) 随需求一并移除;目录保留 `.gitkeep` 以维持路径存在。 --- 验证:G10 铁律15 门禁 20/20 PASS(含变异自检,EXIT=0)、G9 铁律14 门禁 22/22 PASS、 窗口哨兵残留自检 6/6 PASS。前端配套改动见前端仓库提交 1bfe007。
2026-09-21 11:57:58 +08:00
// 与 login 共用同一脱敏出口(同源族:两条 auth 通道口径必须一致,
// 且此处原为全字段 toJSON —— 会连带下发 exam_score/word_score/ppt_score)
const userData = await service.auth.toClientUser(contestant);
ctx.body = ctx.helper.success(userData, '获取用户信息成功');
}
/**
* 忘记密码 - 发送验证码
*/
async forgotPassword() {
const { ctx, service } = this;
const { id_number, captcha } = ctx.request.body;
// 统一输入校验(ctx.validate)
try {
ctx.validate(VALIDATION_RULES.forgotPassword, ctx.request.body);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
if (!ctx.session.captcha || captcha !== ctx.session.captcha) {
ctx.body = ctx.helper.error(6002, '图形验证码错误');
return;
}
const contestant = await service.auth.findByAccount(id_number);
if (!contestant) {
ctx.body = ctx.helper.error(6003, '该身份证号不在参赛名单中');
return;
}
if (!contestant.activated) {
ctx.body = ctx.helper.error(6004, '该账号尚未激活,请先激活');
return;
}
const smsCode = ctx.helper.randomCode(6);
ctx.session.smsCode = smsCode;
ctx.session.smsPhone = contestant.phone;
ctx.session.smsIdNumber = id_number;
ctx.session.smsCodeExpire = Date.now() + 5 * 60 * 1000;
ctx.session.captcha = null;
// 交付验证码(生产环境不回传/不打印,仅走真实网关)
const exposeCode = await this._deliverSmsCode(contestant, smsCode, '忘记密码');
const respData = { phone: contestant.phone };
if (exposeCode) respData.smsCode = smsCode;
ctx.body = ctx.helper.success(respData, '验证码已发送');
}
/**
* 重置密码
*/
async resetPassword() {
const { ctx, service } = this;
const { id_number, smsCode, password } = ctx.request.body;
// 统一输入校验(ctx.validate)
try {
ctx.validate(VALIDATION_RULES.resetPassword, ctx.request.body);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
// 口令强度校验(R63 · C1)—— 与 activate() 共用同一策略(lib/passwordPolicy.js),
// ⚠️ 禁止在本处另行实现:一旦「忘记密码」通道放宽,弱口令缺口会从另一条路重新打开
// (这正是本模块必须单源的原因)。
// 错误码 7001 = 口令强度不足(resetPassword 段编码;activate 段用 2001)。
// 本入口请求体无 phone 字段(见 validate.resetPassword),故仅传 idNumber 做派生值比对。
const pwdCheck = passwordPolicy.checkPassword(password, { idNumber: id_number });
if (!pwdCheck.ok) {
ctx.logger.info(`[口令策略] 重置口令被拒:rule=${pwdCheck.rule}`);
ctx.body = ctx.helper.error(7001, pwdCheck.reason);
return;
}
if (!ctx.session.smsCode || smsCode !== ctx.session.smsCode) {
ctx.body = ctx.helper.error(7004, '短信验证码错误');
return;
}
if (ctx.session.smsCodeExpire && Date.now() > ctx.session.smsCodeExpire) {
ctx.body = ctx.helper.error(7005, '短信验证码已过期');
return;
}
const contestant = await service.auth.findByAccount(id_number);
if (!contestant) {
ctx.body = ctx.helper.error(7006, '账号不存在');
return;
}
const hashedPassword = await ctx.helper.hashPassword(password);
await contestant.update({ password: hashedPassword });
ctx.session.smsCode = null;
ctx.session.smsPhone = null;
ctx.session.smsIdNumber = null;
ctx.session.smsCodeExpire = null;
ctx.body = ctx.helper.success(null, '密码重置成功');
}
/**
* 退出登录
*/
async logout() {
const { ctx } = this;
ctx.body = ctx.helper.success(null, '退出登录成功');
}
/**
* 刷新 Token(静默续期,用户无感知)
*
* 设计目标:让活跃用户永远不会被"意外踢出登录"。
* - 接受可能已过期的令牌(verifyToken 忽略过期)。
* - 仅校验签名有效 + 用户仍真实存在且已激活。
* - 在宽限期内(refreshGrace)允许刷新;超过宽限期则必须重新登录。
* - 该接口刻意不挂 auth 中间件(因为携带的 token 可能已过期)。
*/
async refresh() {
feat(big-question): 中职/高职组「综合大题」在线作答 + 封堵 auth 成绩泄露三通道 ## 一、需求变更:中职/高职组 Word 文件上传题 → 在线「综合大题」 按 2026-09-21 需求变更,中职组 / 高职组取消 Word 文件上传题,改为在线作答 综合大题(1 道大题 × 3 个小问 × 每小问 10 分 = 大题 30 分)。 - 新增表(迁移 023/024) · `big_questions` —— 题库(题干富文本含表格 + 三小问提示语 + 评分口径) · `big_question_answers` —— 作答(抽题快照 + 三小问答案 + 按小问得分) 两份迁移同时提供 `.sql`(生产直跑)与 `.js`(本地/CI 幂等执行)。 - 新增 model:`app/model/bigQuestion.js`、`app/model/bigQuestionAnswer.js` - 新增 service:`app/service/bigQuestion.js`(抽题 / 保存答案 / 评分核心逻辑) - 考生端接口(`app/controller/exam.js`) · `GET /api/exam/:group/big-question` —— 首次抽题并落库题干快照, 后续一律返回同一道题(**幂等**,防止同一考生拿到不同题干引发纠纷) · `POST /api/exam/:group/big-answer` —— 保存三小问答案,带考试窗口门控 - 管理端接口(`app/controller/admin.js`) · 题库 CRUD:`/api/admin/big-questions/:group[/:id]` · 作答查证:`GET /api/admin/big-answers/:group`(考生 → 抽到的题 → 三小问 → 提交时间) · 按小问评分:`PUT /api/admin/big-answers/:group/:uid/scores` - 服务端封堵越界数据:`app/controller/exam.js` 对中职/高职组的 Word 上传接口 一并拒绝(前端入口已移除,但直调 API 仍可绕过)。 **所有新增读写仅落在新增的两张表,不触碰任何既有表结构。** ## 二、安全修复(铁律 15 成绩泄露 —— auth 通道缺口) 原有成绩发布门控只覆盖 4 条通道,遗漏了 auth 的**三条同源通道**:三处都直接 回传 `toJSON()`,在 `score_published_<group> = false`(未发布)时照样下发成绩, 而同期 `GET /api/score` 正确返回 `null`。 - `POST /api/auth/login` —— 实测未发布态回传 `score` - `GET /api/auth/current-user` —— 全字段 `toJSON`,连带下发分项 - `POST /api/auth/refresh` —— 全字段 `toJSON`,且前端 `doSilentRefresh()` 会把响应 `user` 写入 `localStorage` 与 Pinia,等效于登录通道; 实测泄漏 `score` + `exam_score` + `word_score` + `ppt_score` 共 4 个字段 修复方式:新增 `AuthService.toClientUser()` 作为**唯一出口**,三处全部收敛, 禁止各自直接 `toJSON`。口径: - 该组已发布 → 保留 `score`;未发布 / 查询异常 → 剔除 `score`(异常按未发布,安全默认) - `exam_score` / `word_score` / `ppt_score` **任何情况下都不下发** (前端不使用;保留会形成"总分虽隐藏、分项可加总"的旁路) 门禁 `scripts/verify_score_leak_all_channels.py` 相应从 14 项扩到 20 项, 新增通道⑤/⑥/⑦断言(含"发布后 score 必须恢复"的**正向对照**,防止 "永不回传"式过度封堵造成假绿),并经变异注入自检证明断言可证伪。 ## 三、移除已弃用的 Word 模板 中职 / 高职各 3 个历史 Word 模板(`app/public/templates/{zhongzhi,gaozhi}/*.docx`) 随需求一并移除;目录保留 `.gitkeep` 以维持路径存在。 --- 验证:G10 铁律15 门禁 20/20 PASS(含变异自检,EXIT=0)、G9 铁律14 门禁 22/22 PASS、 窗口哨兵残留自检 6/6 PASS。前端配套改动见前端仓库提交 1bfe007。
2026-09-21 11:57:58 +08:00
const { ctx, service } = this;
const authorization = ctx.headers.authorization || '';
const token = authorization.replace(/^Bearer\s+/i, '') || null;
if (!token) {
ctx.body = ctx.helper.error(5001, '未登录');
return;
}
let decoded;
try {
// 忽略过期,仅校验签名与载荷结构,用于静默续期
decoded = ctx.helper.verifyToken(token, this.app.config.jwt.secret, { ignoreExpiration: true });
} catch (e) {
ctx.body = ctx.helper.error(5002, 'Token 无效,请重新登录');
return;
}
if (!decoded || !decoded.id) {
ctx.body = ctx.helper.error(5003, 'Token 无效,请重新登录');
return;
}
// 宽限期校验:仅允许在过期后 refreshGrace 秒内续期,防止会话永久不失效
const nowSec = Math.floor(Date.now() / 1000);
const expiredAgo = decoded.exp ? nowSec - decoded.exp : 99999999;
const refreshGrace = this.app.config.jwt.refreshGrace || 7 * 24 * 60 * 60;
if (expiredAgo > refreshGrace) {
ctx.body = ctx.helper.error(5004, '登录已过期太久,请重新登录');
return;
}
// 校验用户仍真实存在(且选手需已激活)
let account = null;
if (decoded.role === 'admin') {
account = await ctx.model.Admin.findOne({ where: { id: decoded.id } });
} else {
account = await ctx.model.Contestant.findOne({ where: { id: decoded.id } });
if (account && !account.activated) {
ctx.body = ctx.helper.error(5001, '账号未激活,请先激活');
return;
}
}
if (!account) {
ctx.body = ctx.helper.error(5001, '用户不存在,请重新登录');
return;
}
// 签发新令牌(沿用登录时的有效期配置)
const payload = decoded.role === 'admin'
? { id: account.id, role: 'admin' }
: { id: account.id, phone: account.phone, role: 'contestant' };
const newToken = ctx.helper.generateToken(payload, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
feat(big-question): 中职/高职组「综合大题」在线作答 + 封堵 auth 成绩泄露三通道 ## 一、需求变更:中职/高职组 Word 文件上传题 → 在线「综合大题」 按 2026-09-21 需求变更,中职组 / 高职组取消 Word 文件上传题,改为在线作答 综合大题(1 道大题 × 3 个小问 × 每小问 10 分 = 大题 30 分)。 - 新增表(迁移 023/024) · `big_questions` —— 题库(题干富文本含表格 + 三小问提示语 + 评分口径) · `big_question_answers` —— 作答(抽题快照 + 三小问答案 + 按小问得分) 两份迁移同时提供 `.sql`(生产直跑)与 `.js`(本地/CI 幂等执行)。 - 新增 model:`app/model/bigQuestion.js`、`app/model/bigQuestionAnswer.js` - 新增 service:`app/service/bigQuestion.js`(抽题 / 保存答案 / 评分核心逻辑) - 考生端接口(`app/controller/exam.js`) · `GET /api/exam/:group/big-question` —— 首次抽题并落库题干快照, 后续一律返回同一道题(**幂等**,防止同一考生拿到不同题干引发纠纷) · `POST /api/exam/:group/big-answer` —— 保存三小问答案,带考试窗口门控 - 管理端接口(`app/controller/admin.js`) · 题库 CRUD:`/api/admin/big-questions/:group[/:id]` · 作答查证:`GET /api/admin/big-answers/:group`(考生 → 抽到的题 → 三小问 → 提交时间) · 按小问评分:`PUT /api/admin/big-answers/:group/:uid/scores` - 服务端封堵越界数据:`app/controller/exam.js` 对中职/高职组的 Word 上传接口 一并拒绝(前端入口已移除,但直调 API 仍可绕过)。 **所有新增读写仅落在新增的两张表,不触碰任何既有表结构。** ## 二、安全修复(铁律 15 成绩泄露 —— auth 通道缺口) 原有成绩发布门控只覆盖 4 条通道,遗漏了 auth 的**三条同源通道**:三处都直接 回传 `toJSON()`,在 `score_published_<group> = false`(未发布)时照样下发成绩, 而同期 `GET /api/score` 正确返回 `null`。 - `POST /api/auth/login` —— 实测未发布态回传 `score` - `GET /api/auth/current-user` —— 全字段 `toJSON`,连带下发分项 - `POST /api/auth/refresh` —— 全字段 `toJSON`,且前端 `doSilentRefresh()` 会把响应 `user` 写入 `localStorage` 与 Pinia,等效于登录通道; 实测泄漏 `score` + `exam_score` + `word_score` + `ppt_score` 共 4 个字段 修复方式:新增 `AuthService.toClientUser()` 作为**唯一出口**,三处全部收敛, 禁止各自直接 `toJSON`。口径: - 该组已发布 → 保留 `score`;未发布 / 查询异常 → 剔除 `score`(异常按未发布,安全默认) - `exam_score` / `word_score` / `ppt_score` **任何情况下都不下发** (前端不使用;保留会形成"总分虽隐藏、分项可加总"的旁路) 门禁 `scripts/verify_score_leak_all_channels.py` 相应从 14 项扩到 20 项, 新增通道⑤/⑥/⑦断言(含"发布后 score 必须恢复"的**正向对照**,防止 "永不回传"式过度封堵造成假绿),并经变异注入自检证明断言可证伪。 ## 三、移除已弃用的 Word 模板 中职 / 高职各 3 个历史 Word 模板(`app/public/templates/{zhongzhi,gaozhi}/*.docx`) 随需求一并移除;目录保留 `.gitkeep` 以维持路径存在。 --- 验证:G10 铁律15 门禁 20/20 PASS(含变异自检,EXIT=0)、G9 铁律14 门禁 22/22 PASS、 窗口哨兵残留自检 6/6 PASS。前端配套改动见前端仓库提交 1bfe007。
2026-09-21 11:57:58 +08:00
// 返回用户信息 —— 统一走 AuthService.toClientUser 出口
// (与 login / currentUser 同源:禁止各自直接 toJSON,否则成绩未发布时 score 会从本通道漏出)
const userData = await service.auth.toClientUser(account);
ctx.body = ctx.helper.success({
token: newToken,
user: userData,
}, '刷新成功');
}
}
module.exports = AuthController;