Files
competition-website/app/service/auth.js
qubiaobiao 8ef18da395 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

169 lines
5.6 KiB
JavaScript
Raw 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 Service = require('egg').Service;
/**
* 认证服务 - AuthService
*
* 封装认证相关的数据库操作和业务逻辑。
* 使用 Contestant 模型进行选手认证,Admin 模型进行管理员认证。
* 密码采用 bcrypt 加盐哈希存储;存量 SHA256(无盐)密码在下次成功登录时透明升级为 bcrypt。
*/
class AuthService extends Service {
/**
* 根据手机号查找选手
* @param {string} phone - 手机号
* @returns {object|null} 选手信息
*/
async findByPhone(phone) {
const { ctx } = this;
const contestant = await ctx.model.Contestant.findOne({
where: { phone },
attributes: ['id', 'id_number', 'name', 'phone', 'group', 'password', 'activated', 'score'],
});
return contestant;
}
/**
* 根据身份证号查找选手(用于激活和忘记密码)
* @param {string} idNumber - 身份证号
* @returns {object|null} 选手信息
*/
async findByAccount(idNumber) {
const { ctx } = this;
return await ctx.model.Contestant.findOne({
where: { id_number: idNumber },
});
}
/**
* 构造「可下发给考生端」的用户信息(**唯一出口**)。
*
* 2026-09-21 修复(铁律15 成绩泄露全通道封堵 —— 登录通道缺口):
* 此前 `POST /api/auth/login` 与 `GET /api/auth/current-user` 直接回传
* `contestant.toJSON()`,其中 `score` 在成绩**未发布**状态下依然被下发
* (实测未发布态 `score=27`),而同期 `GET /api/score` 正确返回 `null`。
* 即门控只覆盖了 4 条已知通道,遗漏了 auth 这两条同源通道。
*
* 同源族(**三处**必须共用本出口,禁止各自直接 toJSON):
* · controller/auth.js login()
* · controller/auth.js currentUser()
* · controller/auth.js refresh() (`POST /api/auth/refresh`; 前端 doSilentRefresh 会把它写入 localStorage 与 Pinia)
*
* 口径(与 GET /api/score 一致,复用 service.admin.isScorePublished 单一判定):
* · 该组成绩**已发布** → 保留 score;
* · 该组成绩**未发布 / 查询异常** → 剔除 score(安全默认:宁可不显示,不可泄露);
* · 分项字段(exam_score / word_score / ppt_score)**任何情况下都不下发**
* —— 它们只是总分的组成部分,前端从不使用,留在响应里等于给出第二条推算通道。
*
* @param {Model|Object} contestant - Contestant 模型实例或其普通对象
* @returns {Promise<Object|null>} 已脱敏的用户信息
*/
async toClientUser(contestant) {
const { service } = this;
if (!contestant) return null;
const userData = typeof contestant.toJSON === 'function'
? contestant.toJSON()
: Object.assign({}, contestant);
delete userData.password;
// 成绩发布门控:查询失败按「未发布」处理,避免异常路径把分数漏出去
let published = false;
try {
published = await service.admin.isScorePublished(userData.group);
} catch (e) {
published = false;
}
if (!published) {
delete userData.score;
}
// 分项成绩一律不下发(前端不使用;保留会造成「总分虽隐藏、分项可加总」的旁路)
delete userData.exam_score;
delete userData.word_score;
delete userData.ppt_score;
return userData;
}
/**
* 根据用户名查找管理员
*
* @param {string} username - 管理员用户名
* @returns {Promise<Model|null>} Admin 模型对象或 null
*/
async findAdmin(username) {
const { ctx } = this;
return await ctx.model.Admin.findOne({
where: { username },
});
}
/**
* 验证密码
*
* 兼容 bcrypt 哈希与存量 SHA256(无盐)哈希,便于平滑迁移。
*
* @param {string} inputPassword - 用户输入的明文密码
* @param {string} hashedPassword - 数据库中存储的哈希密码
* @returns {Promise<boolean>} 密码是否匹配
*/
async verifyPassword(inputPassword, hashedPassword) {
const { ctx } = this;
return ctx.helper.comparePassword(inputPassword, hashedPassword);
}
/**
* 透明升级:若账号仍使用旧版 SHA256 哈希,在验证通过后重写为 bcrypt。
* 存量账号会在下一次成功登录时自动完成迁移,无需批量刷库。
*
* @param {Model} account - 含 password 字段的模型实例(contestant 或 admin)
* @param {string} plainPassword - 明文密码
* @returns {Promise<void>}
*/
async upgradePasswordIfLegacy(account, plainPassword) {
const { ctx } = this;
if (account && account.password && !ctx.helper.isBcryptHash(account.password)) {
try {
const newHash = await ctx.helper.hashPassword(plainPassword);
await account.update({ password: newHash });
} catch (e) {
// 升级失败不影响本次登录,仅记录日志,下次登录再尝试
ctx.logger.warn('[密码迁移] bcrypt 升级失败:', e.message);
}
}
}
/**
* 激活账号
*
* 将指定 id 的选手设置为已激活状态,并更新密码。
*
* @param {number} id - 选手 id
* @param {string} password - SHA256 哈希后的密码
* @returns {Promise<Model>} 更新后的 Contestant 模型对象
*/
async activateAccount(id, password) {
const { ctx } = this;
const contestant = await ctx.model.Contestant.findOne({
where: { id },
});
if (!contestant) {
return null;
}
return await contestant.update({
activated: 1,
password,
});
}
}
module.exports = AuthService;