Files
competition-website/app/lib/passwordPolicy.js
qubiaobiao 76ac1c7b6b security(config): 四个同族兜底缺陷一次收口 —— APP_KEYS / 弱口令 / IPv6 兜底 / 本地生产密钥同值(R106-D6~D9)
共同根因(R104 结论的延续):**同族问题各自实现必然漂移**。
本仓 DB_PASSWORD 与 JWT_SECRET(R105-D1)已统一为「缺凭据明确失败」,
但同族的另三处仍停在「可预测的公开兜底」上,另新增一处跨环境密钥同值。

D6 · APP_KEYS 派生兜底(原 config.default.js:157)
  原:const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
      config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS;
  「派生」不等于安全:由 appInfo.name 可推算 ⇒ 等价于「Cookie 签名密钥公开
  ⇒ 任何人可伪造已签名 Cookie」。收口到 _secret.cjs 唯一解析入口:
    · scripts/_secret.cjs 抽出通用 _loadSecret(),新增 loadAppKeys()
      (① env → ② scripts/.creds.local.json:app_keys → ③ 明确失败,⛔ 无兜底)
    · 生产安全守卫移除 config.keys === DEFAULT_APP_KEYS 比对分支(已无可比兜底值)
    · scripts/_creds.py 与 .creds.local.example.json 同步键面(两侧同源,永不漂移)

D7 · 生产弱口令兜底(原 config.default.js:176)
  原:config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456';
  而 deploy/.env.production 的 TEST_DEFAULT_PASSWORD 一直是**注释状态** ⇒ 生产
  实际取到的就是弱口令 '123456'。一旦走到「批量写默认口令」的路径(重置测试数据 /
  显式 testFlag=true 的导入)会批量设弱口令 —— 属**静默弱口令**而非明确失败。
  修法:生产(config.isProduction)未显式注入 ⇒ 置 null;使用点改走新增的
  app/lib/passwordPolicy.requireTestPassword() 明确失败。测试环境语义逐字不变。

D8 · 本地 DB 兜底 host 引发 IPv6 建连超时(原 config.default.js:217)
  原:host: process.env.DB_HOST || 'localhost'。Node >= 18 的 verbatim DNS 可能把
  localhost 解析到 ::1,而本机 MariaDB 只听 IPv4 ⇒ **建连 ETIMEDOUT**(不报 1045、
  不报 ECONNREFUSED),现象是「后端启动卡在 DB」,极易误判为「服务没起来」。
  且本仓 scripts/_db.cjs:43 的兜底早已是 '127.0.0.1' —— 同子系统两套默认值必然漂移。
  修法:兜底改为 IPv4 字面量(生产显式注入 DB_HOST ⇒ 逐字不变)。
  配套(config.local.js 为 gitignored 本地文件,不入库):补 host/port 覆盖 ——
  「第二道保险」原本只保账号/口令,反而漏掉了真正会出问题的连接目标。

D9 · 本地 dev 与生产共用同一 JWT/APP_KEYS(loadDeployEnv)
  非生产跳过清单原本只含 DB_/REDIS_/ALIYUN_/SMS_,注释称「仍保留
  JWT_SECRET / APP_KEYS …… 本地无副作用」—— 该结论不成立:本地进程持有
  **生产签名密钥** ⇒ 本地 token 可冒充生产用户(反之亦然),两套环境的会话边界
  实际被打通,属**安全属性**缺陷。
  修法:新增 DEPLOY_ENV_LOCAL_SKIP_KEYS 精确跳过(⛔ 不用前缀以免误伤),本地改由
  scripts/.creds.local.json 供值;Python 侧 scripts/_fixtures.py resolve_jwt_secret()
  同步为「非生产优先本仓凭据文件」—— 否则脚本会用生产密钥签 token 交给本地后端
  验签,必然 401(正是该函数注释里记录过的 23 项级联假红)。

行为等价性(生产逐字不变,已逐项实测):
  · D6/D8:生产显式注入 APP_KEYS / DB_HOST ⇒ 取值路径不变
  · D7:生产 resetTestData 本有 403 守卫、导入默认 testFlag=false ⇒ 原不可达
  · D9:isProdEnv=true ⇒ 不跳过任何键 ⇒ 生产加载路径不变

验证:
  · 配置层双环境断言:本地 jwt=8066e07d5683 / keys=e4cfe40ff3ea(≠ 生产
    a05591750093 / ae0d155385c1);生产两值逐字不变;生产 testDefaultPassword=null
  · 本地后端真实重启(仅给 DB_PORT=3307、**不给 DB_HOST**)⇒ 启动成功,
    /api/health {"code":0,...,"db":"ok"};日志显示跳过 16 个键(含 JWT_SECRET/APP_KEYS)
  · D9 判决点 d9_verify_split.py:本地密钥签 token ⇒ 200;生产密钥 ⇒ **401**
  · 生产部署后 r106_prod_sentinel.js:pass=11 fail=0;/api/health 200
  · 门禁:verify_no_secrets.py VERIFY_ALL_PASS(HIGH 0,判别力自证 17/17);
    verify_script_health.py H1–H4 PASS
2026-10-12 11:06:01 +08:00

232 lines
11 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()` (忘记密码,重设口令)
*
* 不适用范围(重要,勿误改)
* ------------------------------------------------------------
* 以下链路**直写数据库、不经过本策略**,是**设计内**行为,不是绕过:
* · `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 的测试库执行。'
);
}
module.exports = {
checkPassword,
requireTestPassword,
MIN_LENGTH,
MAX_LENGTH,
STRENGTH_HINT,
// 仅供门禁 / 运维脚本断言使用;业务代码请走 checkPassword()
BLACKLIST,
};