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

170 lines
5.7 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 - bcrypt 加盐哈希后的密码(由 helper.hashPassword 生成,
* BCRYPT_ROUNDS=10;调用方 controller/auth.js 的 activate() 已保证此口径)
* @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;