Files
competition-website/app/lib/passwordPolicy.js
qubiaobiao 4f78995f1c fix(auth): 默认口令守卫改为哈希值判据(双通道 + 下沉单源)
R112-㉒:原守卫是「口令字面」判据(DEFAULT_ADMIN_PASSWORDS.includes),任何未枚举的默认口令均绕过。改为双通道并下沉单源:① 哈希通道 isDefaultAdminHash(admin.password) —— 与输入无关,字面比对已发布默认哈希(SHA256('admin') + 固定盐 bcrypt)并以 bcrypt.compareSync 遍历候选口令,覆盖「同口令、不同盐」的重新播种;② 口令通道沿用原逻辑。清单与哈希集下沉至 lib/passwordPolicy.js 成为唯一真相源(controller 删本地声明)。bcrypt 延迟加载 + try/catch ⇒ 无 bcrypt 时降级为仅字面比对且不抛异常。

验证:node --check x2;单测 34/34;变异 3/3 红;仓外判别力自证 45/45 + 4 变异全红;verify_no_secrets HIGH 0/CAND 2(零新增);verify_tautology_guard PASS;verify_constant_shape_guard PASS;6 文件 CR=0。连带失真同步:守卫行 387->394 ⇒ schema.sql:218 + docs/modules.md:581。
2026-10-14 11:06:23 +08:00

323 lines
16 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';
/**
* 口令强度策略 —— 全站**唯一事实来源(SoT)**
* ============================================================
*
* 建立背景(2026-09-30 · R63)
* ------------------------------------------------------------
* R61 测试人员反馈:短信验证后进入「设置密码」页,输入框**已被浏览器密码管理器
* 预填 `123456`**。R62 已从前端修掉两条根因(激活页不清残留登录态 + 密码框未声明
* `autocomplete` 语义),但那是**前端便利性**层面的修复 —— 它只能阻止"自动填",
* 不能阻止"考生自己真的输入 123456 并提交"。实测后端此前只校验「长度 ≥ 6」
* (`app/validate/index.js` 的 activate / resetPassword),`123456` 完全合法
* ⇒ 弱口令仍是**后端可接受**的状态。本模块补上这一层。
*
* 为什么必须只有一个实现
* ------------------------------------------------------------
* 口令校验天然会散落到多个入口(激活 / 忘记密码 / 未来新增的改密),一旦各自
* 实现,必然出现「A 入口拦、B 入口放」的缺口(与项目里 `toClientUser` 单出口、
* `OBJECTIVE_SCORE_PER_QUESTION` 单源同一类教训)。因此:
* ⚠️ **任何新增的口令写入入口,必须调用本模块 `checkPassword()`,禁止自行判断。**
*
* 当前已接入(2/2):
* · `app/controller/auth.js` → `activate()` (短信激活,设置初始口令)
* · `app/controller/auth.js` → `resetPassword()` (忘记密码,重设口令)
*
* 另有一项**非强度**职责也收在本模块(保持「管理员默认凭据」口径单源,见文件末段):
* · `app/controller/auth.js` → `adminLogin()` 的「默认口令守卫」读取本模块的
* `DEFAULT_ADMIN_PASSWORDS` / `isDefaultAdminHash()` —— R112 · 候选㉒ 下沉。
*
* 不适用范围(重要,勿误改)
* ------------------------------------------------------------
* 以下链路**直写数据库、不经过本策略**,是**设计内**行为,不是绕过:
* · `config.default.js` → `testDefaultPassword`(默认 `123456`):
* 测试数据导入(`service/admin.js:240`)与 `resetTestData`(`admin.js:1582`)
* 用它生成哈希写入 `test_flag=1` 的账号,供测试人员免激活重测。
* · 生产数据导入(`test_flag=0`):初始口令为「身份证后 6 位」的 SHA256
* (`service/admin.js:384`),且 `activated=false` —— 必须走激活流程,
* 激活时由考生自填口令**覆盖**该初始值。
* ⇒ 因此本策略的引入**不会破坏**测试数据便利性,也不会影响生产导入。
* 唯一的行为变化:测试人员激活测试账号时不能再把口令设成 `123456` 这类弱口令
* (这正是本次想要的效果,换个口令即可)。
*
* 黑名单为何放在独立 JSON 文件
* ------------------------------------------------------------
* `password_blacklist.json` 是纯数据,供两侧共用,避免出现两份会漂移的清单:
* · 本模块(Node / Egg 运行时)
* · `scripts/` 下的运维脚本(Python,用于扫描存量账号是否命中弱口令)
* 加词只改 JSON,两侧同时生效。
*
* @module lib/passwordPolicy
*/
const BLACKLIST = require('./password_blacklist.json');
/** 口令长度下限(与 `app/validate/index.js` 的 `min` 保持一致) */
const MIN_LENGTH = 6;
/**
* 口令长度上限。
*
* ⚠️ 此前**只有前端**限制 20 位(`Activate.vue` / `ForgotPassword.vue` 的
* `max: 20` 规则),后端 `validate` 无上限 ⇒ 直接调接口可写入超长口令。
* 本值使两侧口径一致;`app/validate/index.js` 同步补 `max: 20`。
*
* 选择 20 而非 72(bcrypt 有效上限)的原因:超长口令在考生端几乎只会来自
* 粘贴误操作,限制上限可减少「以为自己设了长口令、实际记不住」的求助。
*/
const MAX_LENGTH = 20;
/** 面向考生的统一提示(**不泄露**内部规则细节,仅给可执行指引) */
const STRENGTH_HINT = '密码强度不足,请使用 6-20 位且不易被猜到的组合(建议字母+数字+符号,避免连续或重复字符)';
/** 黑名单集合(小写归一化;比对时不区分大小写) */
const BLACKLIST_SET = new Set(BLACKLIST.map(p => String(p).trim().toLowerCase()));
/**
* 归一化:去首尾空白 + 小写。
*
* 说明:去空白是**双保险** —— `ctx.validate` 已用 `trim: true` 处理过一次,
* 但本模块也会被非 HTTP 场景(脚本 / 单测)直接调用,不能假设上游已清理。
*
* @param {*} v
* @returns {string}
*/
function normalize(v) {
return String(v === null || v === undefined ? '' : v).trim().toLowerCase();
}
/**
* 判断是否「纯重复字符」——如 `aaaaaa` / `@@@@@@` / `111111`。
*
* 用正则 `^(.)\1*$` 一次判定:首字符捕获后必须整体重复。
* 注意 `.` 在 JS 中不匹配换行符,口令含换行会自然落到"否",无副作用。
*
* @param {string} s 已归一化
* @returns {boolean}
*/
function isRepeatedChar(s) {
return s.length >= MIN_LENGTH && /^(.)\1*$/.test(s);
}
/**
* 判断是否「整串连续序列」——如 `123456`(递增)/ `654321`(递减)/
* `abcdef`(字母递增)/ `fedcba`(字母递减)。
*
* 判据:每个相邻字符的字符码点差**恒为 +1 或恒为 -1**。
* 刻意只判「整串」:`a1b2c3` / `abcd1234` 这类混合串**不**触发本规则
* (它们的风险由黑名单显式覆盖),避免误伤正常口令。
*
* @param {string} s 已归一化
* @returns {boolean}
*/
function isSequential(s) {
if (s.length < MIN_LENGTH) return false;
let up = true;
let down = true;
for (let i = 1; i < s.length; i++) {
const diff = s.charCodeAt(i) - s.charCodeAt(i - 1);
if (diff !== 1) up = false;
if (diff !== -1) down = false;
if (!up && !down) return false;
}
return up || down;
}
/**
* 校验口令强度。
*
* 规则(按执行顺序,命中即返回,**首个命中项**决定 reason):
* 1. 非空且为字符串
* 2. 长度在 [MIN_LENGTH, MAX_LENGTH] 区间
* 3. 不含空白字符(防止「看起来 8 位、实际含空格」被前端 min 误判通过)
* 4. 不在静态黑名单(大小写不敏感)
* 5. 非纯重复字符
* 6. 非整串连续序列
* 7. 不等于**账号自身的可猜派生值**:完整身份证号 / 身份证号后 6 位 / 手机号
* —— 生产账号的初始口令正是「身份证后 6 位」的 SHA256(`service/admin.js:384`),
* 考生激活时若直接沿用该值,等于口令从未变更,必须拒绝。
*
* @param {string} password - 明文口令
* @param {{idNumber?: string, phone?: string}} [context] - 账号上下文(用于派生值比对)
* @returns {{ok: boolean, rule: string, reason: string}}
* ok=false 时 `rule` 为命中的规则标识(供门禁/日志精确断言,**不下发前端**),
* `reason` 为面向考生的中文文案(可直接返回给前端)。
*/
function checkPassword(password, context = {}) {
const raw = String(password === null || password === undefined ? '' : password);
if (raw.length === 0) {
return { ok: false, rule: 'empty', reason: '请输入密码' };
}
const normalized = normalize(raw);
if (normalized.length < MIN_LENGTH) {
return { ok: false, rule: 'too_short', reason: `密码长度不能少于 ${MIN_LENGTH} 位` };
}
if (normalized.length > MAX_LENGTH) {
return { ok: false, rule: 'too_long', reason: `密码长度不能超过 ${MAX_LENGTH} 位` };
}
if (/\s/.test(raw)) {
return { ok: false, rule: 'contains_space', reason: '密码不能包含空格' };
}
if (BLACKLIST_SET.has(normalized)) {
return { ok: false, rule: 'blacklist', reason: STRENGTH_HINT };
}
if (isRepeatedChar(normalized)) {
return { ok: false, rule: 'repeated_char', reason: STRENGTH_HINT };
}
if (isSequential(normalized)) {
return { ok: false, rule: 'sequential', reason: STRENGTH_HINT };
}
// 派生值比对:口令不得等于账号自身可猜出的标识
const idNumber = normalize(context.idNumber);
const phone = normalize(context.phone);
if (idNumber) {
if (normalized === idNumber) {
return { ok: false, rule: 'equals_id_number', reason: '密码不能与身份证号相同' };
}
if (idNumber.length >= MIN_LENGTH && normalized === idNumber.slice(-MIN_LENGTH)) {
return { ok: false, rule: 'equals_id_suffix', reason: '密码不能使用身份证号后 6 位' };
}
}
if (phone && normalized === phone) {
return { ok: false, rule: 'equals_phone', reason: '密码不能与手机号相同' };
}
return { ok: true, rule: 'ok', reason: '' };
}
/**
* 解析「测试数据默认口令」—— **唯一入口**(R106-D7,2026-10-12)。
*
* 背景:`config.testDefaultPassword` 原为无条件 `process.env.TEST_DEFAULT_PASSWORD || '123456'`,
* 而 `deploy/.env.production` 的 `TEST_DEFAULT_PASSWORD` 一直是**注释状态** ⇒ 生产环境实际
* 取到的就是弱口令 `'123456'`。一旦生产走到「批量写默认口令」的路径(重置测试数据 / 显式
* `testFlag=true` 的 Excel 导入),一批账号会被设为 `'123456'` —— 这是**静默弱口令**,
* 而不是"注入链路断了"的明确失败,与 `_secret.cjs` / `DB_PASSWORD` 的既有纪律不一致
* (R104 结论:同族问题各自实现必然漂移)。
*
* 现约定:生产(`config.isProduction`)未显式注入 ⇒ `config.testDefaultPassword` 为 `null`
* ⇒ 本函数**明确失败**并给出可行动指引(⛔ 不静默回退)。测试环境逐字不变(仍 `'123456'`)。
*
* ⚠️ 与 `controller.admin.resetTestData` 里 `process.env.NODE_ENV === 'production'` 的
* 403 守卫**口径不同**(后者只看 NODE_ENV,本函数看 `config.isProduction`,即
* `APP_ENV`/`appInfo.env`)⇒ 两者互为补充:守卫拦"明确的生产",本函数兜住
* "`APP_ENV=production` 但 NODE_ENV 未设"这种分叉情形。
*
* @param {object} appConfig Egg `app.config`
* @returns {string} 生效的测试默认口令
*/
function requireTestPassword(appConfig) {
const pw = appConfig && appConfig.testDefaultPassword;
if (pw) return pw;
throw new Error(
'[安全阻断] 生产环境未配置 TEST_DEFAULT_PASSWORD —— 拒绝把账号批量设为「默认口令」。\n' +
' 该操作会写入 config.testDefaultPassword 的 bcrypt 哈希;若静默回退到内置弱口令\n' +
" (原行为 '123456'),等价于在生产上批量设置弱口令。请任选其一:\n" +
' ① 确实需要:在 deploy/.env.production 显式设置 TEST_DEFAULT_PASSWORD 后重启\n' +
' (推荐 —— 让「生产要设默认口令」成为一个显式决定);\n' +
' ② 若本就不该在生产重置测试数据,请在带 test_flag 的测试库执行。'
);
}
/**
* ── 内置默认管理员凭据(登录守卫的唯一真相源)──────────────────────
*
* 背景(BACKLOG 候选㉒ · R112)
* ------------------------------------------------------------
* `controller/auth.js` 的「默认口令守卫」原为**口令字面**判据(用户输入的明文
* 是否命中一张硬编码清单)。该判据有两个结构缺口:
* ① 清单**散落在 controller 内** —— 与 `database/schema.sql`、`backend/database/seeders/002-seed-admin.js`
* 各持一份口径,属「多真相源」,任一处改动都不会带动其余(R111 血证:守卫曾漏 `admin`);
* ② 只覆盖**已枚举**的默认口令 —— 任何未列出的内置默认口令(或清单被改动)即**静默绕过**。
*
* 治法:把「默认凭据」下沉到本模块(口令策略 SoT)成为**单一真相源**,并补一条
* **与用户输入无关**的判据 —— 直接看 DB 里存的哈希是否即已发布的默认哈希。
*
* ⚠️ bcrypt 的盐是**随机**的:同一口令每次哈希结果都不同 ⇒ ⛔ 不能只比对哈希字符串。
* 故本模块提供**两条互补通道**,由调用方 OR 合并(见 `controller/auth.js adminLogin`):
* · 哈希通道 `isDefaultAdminHash(dbHash)` —— 覆盖「DB 里就是内置的那串哈希」
* (`schema.sql` 的无盐 SHA256 + seeder 002 的固定盐 bcrypt),**与输入无关**;
* · 口令通道 `DEFAULT_ADMIN_PASSWORDS.includes(明文)` —— 覆盖「同口令、不同盐」
* 的**重新播种**场景(登录成功的明文必等于该默认口令,见 `002-seed-admin.js` 的
* `ADMIN_INIT_PASSWORD` 分支会以随机盐重算哈希)。
* 二者合并即覆盖「**已知**默认口令的任意存储形态」。
* ⚠️ 未列入清单的默认口令仍是缺口 ⇒ 新增内置账号时**必须同步登记到下方清单**
* (这是**刻意的显式维护点**,避免静默逃逸)。
* ⚠️ 已知缺口(如实登记):**目前尚无仓内机检**覆盖本清单 —— 漏登记不会自动报红
* (R112 的判别力自证属一次性仓外驱动,未下沉为常驻门禁)⇒ 需**人工核对**
* 「本清单 ↔ `database/schema.sql` ↔ seeder 002」三处。
*/
/** 内置默认管理员口令(**唯一真相源**;登录守卫与文档口径均以此为准) */
const DEFAULT_ADMIN_PASSWORDS = ['admin', 'admin123', 'admin123456'];
/**
* 已发布默认管理员凭据的**字面哈希**(固定值,可精确比对)。
* · `8c69…4918`:SHA256(无盐) ← `database/schema.sql` 的建库 INSERT;
* · `$2b$10$Nc6…co9.`:bcrypt(cost=10, 固定盐) ← `backend/database/seeders/002-seed-admin.js`。
*/
const KNOWN_DEFAULT_ADMIN_HASHES = [
'8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918',
'$2b$10$Nc6PCJItIwuOzpM22dTiAezB.jZMI8mzkCbg3oRZkAWx.QaI4co9.',
];
/** 字面哈希集合(小写归一化;bcrypt 前缀 `$2B$` 亦见于部分工具) */
const KNOWN_DEFAULT_ADMIN_HASH_SET =
new Set(KNOWN_DEFAULT_ADMIN_HASHES.map(h => h.toLowerCase()));
/**
* 判定「DB 中存储的凭据哈希」是否为**已发布的默认值**(与用户输入无关)。
*
* 两级判定:
* ① 字面命中 `KNOWN_DEFAULT_ADMIN_HASHES`(覆盖无盐 SHA256 与固定盐 bcrypt 两种存量形态);
* ② 逐个候选默认口令做 bcrypt 校验(覆盖「同口令、**不同盐**」的重新播种 bcrypt 值 ——
* 这正是「不能只比哈希字符串」的原因)。
*
* ⚠️ 降级语义:`bcrypt` 采用**延迟加载**。本模块会被脚本 / 离线上下文(如变异测试写到
* 同目录的临时副本)加载,若该上下文取不到 `bcrypt`,则退化为「仅字面比对」(② 不可用),
* 且**不抛异常**(守卫降级属可接受,⛔ 不得因此阻断进程启动)。
*
* @param {string} dbHash - `admins.password` 的存储值
* @returns {boolean} 命中即 true
*/
function isDefaultAdminHash(dbHash) {
if (!dbHash) return false;
const h = String(dbHash).trim();
if (!h) return false;
if (KNOWN_DEFAULT_ADMIN_HASH_SET.has(h.toLowerCase())) return true;
let bcrypt = null;
try {
bcrypt = require('bcrypt');
} catch (e) {
return false; // 无 bcrypt ⇒ 退化为「仅字面比对」
}
for (const pw of DEFAULT_ADMIN_PASSWORDS) {
try {
if (bcrypt.compareSync(pw, h)) return true;
} catch (e) {
// 非法 bcrypt 形态(如 SHA256 十六进制串)⇒ 跳过,继续下一候选
}
}
return false;
}
module.exports = {
checkPassword,
requireTestPassword,
MIN_LENGTH,
MAX_LENGTH,
STRENGTH_HINT,
// 仅供门禁 / 运维脚本断言使用;业务代码请走 checkPassword()
BLACKLIST,
// 默认管理员凭据判据(登录守卫用;见上文「内置默认管理员凭据」段)
DEFAULT_ADMIN_PASSWORDS,
KNOWN_DEFAULT_ADMIN_HASHES,
isDefaultAdminHash,
};