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:真接口验证(含「强口令通过」正向对照臂,防假绿)。
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
const Controller = require('egg').Controller;
|
||||
const svgCaptcha = require('svg-captcha');
|
||||
const VALIDATION_RULES = require('../validate');
|
||||
// 口令强度策略(唯一事实来源):黑名单 + 纯重复/序列 + 账号派生值 + 长度上下限。
|
||||
// ⚠️ 任何新增的口令写入入口都必须调用它,禁止在控制器内自行判断(见 lib/passwordPolicy.js 模块头)。
|
||||
const passwordPolicy = require('../lib/passwordPolicy');
|
||||
|
||||
/**
|
||||
* 认证控制器 - AuthController
|
||||
@@ -206,6 +209,21 @@ class AuthController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// 口令强度校验(R63 · C1)
|
||||
// 背景:此前仅由 validate 校验「长度 ≥ 6」,`123456` 完全合法 —— R61 测试人员
|
||||
// 实测「浏览器密码管理器预填 123456,直接提交后即成为账号口令」。
|
||||
// 位置(validate 之后 / 短信码之前)的理由:
|
||||
// · validate 只管「必填 + 格式」,强度是业务规则,分层后两者失败文案互不覆盖;
|
||||
// · 纯本地计算、无副作用,可快速失败,不消耗也不依赖 session 中的验证码。
|
||||
// 错误码 2001 = 口令强度不足(activate 段编码;resetPassword 段用 7001)。
|
||||
// ⚠️ 日志只记命中的规则名,绝不记录口令明文或身份证号。
|
||||
const pwdCheck = passwordPolicy.checkPassword(password, { idNumber: id_number, phone });
|
||||
if (!pwdCheck.ok) {
|
||||
ctx.logger.info(`[口令策略] 激活口令被拒:rule=${pwdCheck.rule}`);
|
||||
ctx.body = ctx.helper.error(2001, pwdCheck.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验短信验证码
|
||||
if (!ctx.session.smsCode || smsCode !== ctx.session.smsCode) {
|
||||
ctx.body = ctx.helper.error(2005, '短信验证码错误');
|
||||
@@ -500,6 +518,18 @@ class AuthController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// 口令强度校验(R63 · C1)—— 与 activate() 共用同一策略(lib/passwordPolicy.js),
|
||||
// ⚠️ 禁止在本处另行实现:一旦「忘记密码」通道放宽,弱口令缺口会从另一条路重新打开
|
||||
// (这正是本模块必须单源的原因)。
|
||||
// 错误码 7001 = 口令强度不足(resetPassword 段编码;activate 段用 2001)。
|
||||
// 本入口请求体无 phone 字段(见 validate.resetPassword),故仅传 idNumber 做派生值比对。
|
||||
const pwdCheck = passwordPolicy.checkPassword(password, { idNumber: id_number });
|
||||
if (!pwdCheck.ok) {
|
||||
ctx.logger.info(`[口令策略] 重置口令被拒:rule=${pwdCheck.rule}`);
|
||||
ctx.body = ctx.helper.error(7001, pwdCheck.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.session.smsCode || smsCode !== ctx.session.smsCode) {
|
||||
ctx.body = ctx.helper.error(7004, '短信验证码错误');
|
||||
return;
|
||||
|
||||
196
app/lib/passwordPolicy.js
Normal file
196
app/lib/passwordPolicy.js
Normal file
@@ -0,0 +1,196 @@
|
||||
'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,
|
||||
};
|
||||
86
app/lib/password_blacklist.json
Normal file
86
app/lib/password_blacklist.json
Normal file
@@ -0,0 +1,86 @@
|
||||
[
|
||||
"123456",
|
||||
"1234567",
|
||||
"12345678",
|
||||
"123456789",
|
||||
"1234567890",
|
||||
"654321",
|
||||
"0987654321",
|
||||
"111111",
|
||||
"000000",
|
||||
"222222",
|
||||
"333333",
|
||||
"444444",
|
||||
"555555",
|
||||
"666666",
|
||||
"777777",
|
||||
"888888",
|
||||
"999999",
|
||||
"123123",
|
||||
"112233",
|
||||
"121212",
|
||||
"123321",
|
||||
"159357",
|
||||
"5201314",
|
||||
"1314520",
|
||||
"666888",
|
||||
"168168",
|
||||
"a123456",
|
||||
"123456a",
|
||||
"123456789a",
|
||||
"password",
|
||||
"passw0rd",
|
||||
"password1",
|
||||
"password123",
|
||||
"p@ssw0rd",
|
||||
"admin",
|
||||
"admin123",
|
||||
"admin888",
|
||||
"administrator",
|
||||
"root",
|
||||
"root123",
|
||||
"qwerty",
|
||||
"qwertyuiop",
|
||||
"qwerty123",
|
||||
"abc123",
|
||||
"abcd1234",
|
||||
"abcabc",
|
||||
"aa123456",
|
||||
"iloveyou",
|
||||
"letmein",
|
||||
"welcome",
|
||||
"monkey",
|
||||
"dragon",
|
||||
"master",
|
||||
"sunshine",
|
||||
"princess",
|
||||
"football",
|
||||
"baseball",
|
||||
"superman",
|
||||
"hello123",
|
||||
"test123",
|
||||
"test1234",
|
||||
"zxcvbnm",
|
||||
"asdfgh",
|
||||
"asdfghjkl",
|
||||
"qazwsx",
|
||||
"1qaz2wsx",
|
||||
"1qazxsw2",
|
||||
"qwe123",
|
||||
"qweasd",
|
||||
"q1w2e3",
|
||||
"pass123",
|
||||
"p123456",
|
||||
"123abc",
|
||||
"abcd123",
|
||||
"a1b2c3",
|
||||
"beidou",
|
||||
"beidouin",
|
||||
"brskills",
|
||||
"competition",
|
||||
"competition123",
|
||||
"contest",
|
||||
"contest2026",
|
||||
"hwasmart",
|
||||
"hwasmart123"
|
||||
]
|
||||
@@ -10,8 +10,14 @@
|
||||
* 规则说明:
|
||||
* - type: 'string' + required: true + allowEmpty: false:字段必填且不可为空串/纯空白
|
||||
* - trim: true:校验前去除首尾空白
|
||||
* - min: 6:字符串最小长度(用于密码强度底线)
|
||||
* - min / max:字符串长度区间(口令口径 6-20,与前端 el-form 规则保持一致)
|
||||
* - message:为每个校验项提供中文错误文案,parameter 会在 errors[] 中携带对应 message
|
||||
*
|
||||
* ⚠️ 口令的**强度**(弱口令黑名单 / 纯重复字符 / 连续序列 / 账号派生值)**不在本文件**。
|
||||
* 本文件只负责「必填 + 长度格式」这一层;强度规则由 app/lib/passwordPolicy.js
|
||||
* 单源提供,调用点在 controller/auth.js 的 activate() 与 resetPassword()。
|
||||
* 分层理由:同一份强度规则需被多个口令写入口共用,并须能被运维脚本复用
|
||||
* (脚本要扫描存量账号是否命中弱口令),散落实现必然出现「一处拦、一处放」。
|
||||
*/
|
||||
|
||||
const required = (msg) => ({
|
||||
@@ -52,10 +58,12 @@ module.exports = {
|
||||
allowEmpty: false,
|
||||
trim: true,
|
||||
min: 6,
|
||||
max: 20,
|
||||
message: {
|
||||
required: '请输入密码',
|
||||
empty: '请输入密码',
|
||||
min: '密码长度不能少于 6 位',
|
||||
max: '密码长度不能超过 20 位',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -76,10 +84,12 @@ module.exports = {
|
||||
allowEmpty: false,
|
||||
trim: true,
|
||||
min: 6,
|
||||
max: 20,
|
||||
message: {
|
||||
required: '请输入密码',
|
||||
empty: '请输入密码',
|
||||
min: '密码长度不能少于 6 位',
|
||||
max: '密码长度不能超过 20 位',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user