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。
170 lines
5.7 KiB
JavaScript
170 lines
5.7 KiB
JavaScript
'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;
|