Files
competition-website/app/lib/passwordPolicy.js
qubiaobiao a2e24aba2a feat(auth): 口令强度策略(弱口令黑名单 / 账号派生值 / 长度上限)
背景(R61 实测):浏览器密码管理器在「设置密码」页预填 123456,考生若直接提交,该口令即成为其账号真实口令;而后端此前仅由 validate 校验「长度 >= 6」,弱口令完全合法。R62 已从前端修掉预填,本次补齐后端这道闸门。

- app/lib/passwordPolicy.js + password_blacklist.json:口令策略唯一事实来源。黑名单刻意做成纯 JSON,供 Node 运行时与运维脚本共用,避免两份清单漂移。

- controller/auth.js:activate() 与 resetPassword() 接入策略(错误码 2001 / 7001),位置在 validate 之后、短信校验之前(纯本地计算、无副作用、快速失败)。

- validate/index.js:补 max:20,与前端 el-form 规则对齐(此前只有前端限制长度上限)。

- scripts/_db.cjs:DB 配置解析唯一入口;scan_weak_password_accounts.cjs:存量弱口令只读扫描器;verify_password_policy_api.cjs:真接口验证(含「强口令通过」正向对照臂,防假绿)。
2026-09-30 11:56:35 +08:00

197 lines
8.6 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: '' };
}
module.exports = {
checkPassword,
MIN_LENGTH,
MAX_LENGTH,
STRENGTH_HINT,
// 仅供门禁 / 运维脚本断言使用;业务代码请走 checkPassword()
BLACKLIST,
};