'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, };