Files
competition-website/app/lib/passwordPolicy.js

327 lines
16 KiB
JavaScript
Raw Normal View History

'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: '' };
}
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
/**
* 解析「测试数据默认口令」—— **唯一入口**(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 里就是内置的那串哈希」
* (R113 前 `schema.sql` 的无盐 SHA256;R113 起 `schema.sql` 与 `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(无盐) —— **R113 前** `database/schema.sql` 建库 INSERT 的产物;
* 自 **R113(候选㉔)** 起该文件已统一为下方 bcrypt ⇒ 本项作为**历史值保留**,
* 用于拦截仍存该哈希的旧库 / 旧备份(⛔ 不删:保留即安全 —— 非默认口令不会命中)。
* · `$2b$10$Nc6…co9.`:bcrypt(cost=10, 固定盐) —— 来源**两处已收敛为同值**:
* `database/schema.sql` 的建库 INSERT + `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,
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
requireTestPassword,
MIN_LENGTH,
MAX_LENGTH,
STRENGTH_HINT,
// 仅供门禁 / 运维脚本断言使用;业务代码请走 checkPassword()
BLACKLIST,
// 默认管理员凭据判据(登录守卫用;见上文「内置默认管理员凭据」段)
DEFAULT_ADMIN_PASSWORDS,
KNOWN_DEFAULT_ADMIN_HASHES,
isDefaultAdminHash,
};