diff --git a/app/controller/auth.js b/app/controller/auth.js index 52ef3ba..78768da 100644 --- a/app/controller/auth.js +++ b/app/controller/auth.js @@ -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; diff --git a/app/lib/passwordPolicy.js b/app/lib/passwordPolicy.js new file mode 100644 index 0000000..8df188a --- /dev/null +++ b/app/lib/passwordPolicy.js @@ -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, +}; diff --git a/app/lib/password_blacklist.json b/app/lib/password_blacklist.json new file mode 100644 index 0000000..2b373b8 --- /dev/null +++ b/app/lib/password_blacklist.json @@ -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" +] diff --git a/app/validate/index.js b/app/validate/index.js index 1aa85a7..c3ced52 100644 --- a/app/validate/index.js +++ b/app/validate/index.js @@ -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 位', }, }, }, diff --git a/scripts/_db.cjs b/scripts/_db.cjs new file mode 100644 index 0000000..92d7f9b --- /dev/null +++ b/scripts/_db.cjs @@ -0,0 +1,61 @@ +'use strict'; + +/** + * 后端运维脚本共用:数据库连接配置解析(**唯一入口**) + * ============================================================ + * + * 为什么单独成文件 + * ------------------------------------------------------------ + * 任何"需要连库"的运维脚本(弱口令扫描 / 口令策略接口验证 / 后续新增)都必须 + * 用同一套优先级与同一条凭据纪律。各自实现必然漂移 —— 例如某处"缺口令时兜底成 + * 空串"就能静默连上无口令的库,而另一处明确失败,两者行为不一致却都"看起来正常"。 + * + * 读取优先级(与 scripts/_creds.py 完全同源) + * ------------------------------------------------------------ + * 1. 环境变量 DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAME(CI / 服务器注入优先) + * 2. 仓库根 `scripts/.creds.local.json`(本机开发,已被 .gitignore 排除) + * 3. 明确失败 —— ⛔ **刻意不提供明文兜底**:口令为空时直接抛错, + * 绝不静默尝试空口令连库(那会掩盖"凭据没注入"这一真实故障)。 + * + * ⚠️ Python 侧对应实现见 `scripts/_creds.py`,两侧口径必须一致; + * 如需增删键,**两边同时改**。 + * + * @returns {{host: string, port: number, user: string, password: string, database: string}} + */ +const fs = require('fs'); +const path = require('path'); + +function loadDbConfig() { + const env = process.env; + let local = {}; + // backend/scripts/ → 仓库根/scripts/.creds.local.json + const localFile = path.join(__dirname, '..', '..', 'scripts', '.creds.local.json'); + try { + local = JSON.parse(fs.readFileSync(localFile, 'utf8')); + } catch (e) { + // 文件不存在是允许的(走环境变量);但**损坏**必须报错,不静默降级 + if (e.code !== 'ENOENT') { + throw new Error(`凭据文件解析失败 ${localFile}: ${e.message}`); + } + } + + const cfg = { + host: env.DB_HOST || local.db_host || '127.0.0.1', + port: parseInt(env.DB_PORT || local.db_port || 3306, 10), + user: env.DB_USER || local.db_user || 'root', + password: env.DB_PASSWORD || local.db_password || '', + database: env.DB_NAME || local.db_name || 'competition_db', + }; + + if (!cfg.password) { + throw new Error( + '未取到数据库口令。请任选其一:\n' + + ' ① 设置环境变量 DB_PASSWORD\n' + + ' ② 创建 scripts/.creds.local.json(形如 {"db_password":"..."})\n' + + '⛔ 本模块刻意不提供明文兜底:缺凭据时明确失败,不静默连库。' + ); + } + return cfg; +} + +module.exports = { loadDbConfig }; diff --git a/scripts/scan_weak_password_accounts.cjs b/scripts/scan_weak_password_accounts.cjs new file mode 100644 index 0000000..117238f --- /dev/null +++ b/scripts/scan_weak_password_accounts.cjs @@ -0,0 +1,274 @@ +#!/usr/bin/env node +'use strict'; + +/** + * 弱口令存量账号扫描器(R63 · C2) + * ============================================================ + * + * 用途 + * ------------------------------------------------------------ + * R61 测试人员实测:进入「设置密码」页时浏览器密码管理器**预填 `123456`**, + * 若考生**直接提交**,该口令就成为其账号的真实口令。R62 已从前端修掉预填 + * (激活页清残留登录态 + 密码框声明 autocomplete 语义),但**存量**账号里 + * 可能已经存在这种口令。本脚本负责**精确识别**它们。 + * + * ⛔ 本脚本是**只读**的 + * ------------------------------------------------------------ + * 全程只执行 `SELECT`。**不做任何 UPDATE / DELETE / INSERT**,也不删文件。 + * 识别结果仅用于人工决策(是否重设口令、如何通知考生)。 + * —— 口令哈希不可逆,唯一可靠的识别方式就是"拿候选口令逐条 bcrypt 比对"。 + * + * 为什么用 Node 而不是 Python + * ------------------------------------------------------------ + * 必须与后端**同一个 bcrypt 实现**(`backend/node_modules/bcrypt`,原生模块)比对, + * 避免跨语言实现差异带来的假阴性;且本机 Python 无 bcrypt 依赖(服务器端有 + * `/opt/pytools/lib`,但"同一实现"比"能跑"更重要)。黑名单直接从后端策略的 + * JSON 读取,**不复制一份**(复制必然漂移)。 + * + * 判据设计(避免把"没扫到"误读成"没问题") + * ------------------------------------------------------------ + * · 命中即短路:每行按候选顺序比对,一旦命中就停止(`123456` 排在最前, + * 它是本次风险的核心)。 + * · 未激活账号默认跳过:其口令来自导入时的派生值(生产 = 身份证后 6 位 SHA256, + * 见 `app/service/admin.js:384`),**不是**考生自己设置的,扫描无意义。 + * 如需连未激活一起看,加 `--all`。 + * · 输出末尾打印**扫描覆盖面**(扫了多少行 / 比对多少次 / 跳过多少行), + * 让"零命中"成为一个有依据的结论,而不是空输出。 + * + * 用法 + * ------------------------------------------------------------ + * # 本地库(凭据取自 scripts/.creds.local.json,或环境变量) + * node backend/scripts/scan_weak_password_accounts.cjs + * + * # 追加自定义候选口令 + * node backend/scripts/scan_weak_password_accounts.cjs --extra MyPwd123 + * + * # 连未激活账号一起扫 + * node backend/scripts/scan_weak_password_accounts.cjs --all + * + * # 机器可读输出 + * node backend/scripts/scan_weak_password_accounts.cjs --json + * + * 退出码 + * 0 = 扫描完成(无论是否命中;命中数在输出中提示) + * 2 = 参数错误 / 连接失败 + */ + +const crypto = require('crypto'); +const mysql = require('mysql2/promise'); +const bcrypt = require('bcrypt'); + +/** 黑名单 —— 与后端策略共用同一份 JSON(单一事实来源,勿另存副本) */ +const BLACKLIST = require('../app/lib/password_blacklist.json'); + +/** bcrypt 哈希识别(与 extend/helper.js 的 isBcryptHash 同口径) */ +function isBcryptHash(h) { + return typeof h === 'string' && /^\$2[aby]\$/.test(h); +} + +/** 存量 SHA256(无盐)哈希 */ +function sha256(s) { + return crypto.createHash('sha256').update(s).digest('hex'); +} + +/** + * DB 配置解析 —— 与其它运维脚本共用同一入口。 + * ⚠️ 不在此处另写一份:优先级与「缺口令即明确失败」的纪律必须全脚本一致。 + */ +const { loadDbConfig } = require('./_db.cjs'); + +/** 身份证号脱敏:保留前 6 后 4 */ +function maskIdNumber(v) { + const s = String(v || ''); + if (s.length <= 10) return s ? s[0] + '***' : ''; + return `${s.slice(0, 6)}****${s.slice(-4)}`; +} + +/** 命令行参数解析(极简,无需依赖) */ +function parseArgs(argv) { + const opts = { extra: [], all: false, json: false, quiet: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--all') opts.all = true; + else if (a === '--json') opts.json = true; + else if (a === '--quiet') opts.quiet = true; + else if (a === '--extra') opts.extra.push(argv[++i]); + else if (a === '--help' || a === '-h') opts.help = true; + else { + console.error(`未知参数:${a}(用 --help 查看用法)`); + process.exit(2); + } + } + return opts; +} + +/** + * 逐行比对:命中即返回命中的候选口令,未命中返回 null。 + * + * @param {object} row - 含 password 的账号行 + * @param {string[]} candidates - 候选口令(已去重、已排序) + * @returns {Promise} + */ +async function findWeakPassword(row, candidates) { + const hash = row.password; + if (!hash) return null; + const bcryptHash = isBcryptHash(hash); + + for (const candidate of candidates) { + if (bcryptHash) { + // 慢路径(每条约 0.1-0.3s,取决于 cost) + // eslint-disable-next-line no-await-in-loop + if (await bcrypt.compare(candidate, hash)) return candidate; + } else { + // 存量 SHA256:瞬时 + if (sha256(candidate) === hash) return candidate; + } + } + return null; +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + + if (opts.help) { + console.log(` +弱口令存量账号扫描器(只读) + +用法: + node backend/scripts/scan_weak_password_accounts.cjs [选项] + +选项: + --extra 追加自定义候选口令(可重复) + --all 连未激活账号一并扫描(默认只扫 activated=1) + --json 以 JSON 输出结果 + --quiet 只输出汇总 + --help 显示本帮助 +`); + return 0; + } + + // 候选口令:123456 优先(本次风险核心),其余按黑名单顺序;去重 + const seen = new Set(); + const candidates = []; + for (const p of ['123456', ...BLACKLIST, ...opts.extra]) { + const s = String(p); + if (!seen.has(s)) { + seen.add(s); + candidates.push(s); + } + } + + const cfg = loadDbConfig(); + const conn = await mysql.createConnection({ + host: cfg.host, + port: cfg.port, + user: cfg.user, + password: cfg.password, + database: cfg.database, + }); + + try { + // ── 只读扫描 ──────────────────────────────────────── + // 列名以 model 定义为准(contestants):id / name / id_number / phone / group / + // activated / test_flag / password。⛔ 不可跨表套用其它表的列名。 + const sql = [ + 'SELECT id, name, id_number, phone, `group`, activated, test_flag, password', + 'FROM contestants', + opts.all ? '' : 'WHERE activated = 1', + 'ORDER BY id', + ].filter(Boolean).join(' '); + + const [rows] = await conn.query(sql); + + const total = rows.length; + const skipped = rows.filter(r => !r.password).length; + let comparisons = 0; + const hits = []; + let bcryptRows = 0; + + for (const row of rows) { + if (!row.password) continue; + if (isBcryptHash(row.password)) bcryptRows++; + comparisons += candidates.length; + // eslint-disable-next-line no-await-in-loop + const matched = await findWeakPassword(row, candidates); + if (matched) { + hits.push({ + id: row.id, + name: row.name, + id_number: maskIdNumber(row.id_number), + group: row.group, + activated: row.activated, + test_flag: row.test_flag, + matched_password: matched, + hash_type: isBcryptHash(row.password) ? 'bcrypt' : 'sha256', + }); + } + } + + // ── 输出 ──────────────────────────────────────────── + if (opts.json) { + console.log(JSON.stringify({ + scanned: total, + skipped_no_password: skipped, + bcrypt_rows: bcryptRows, + candidates: candidates.length, + comparisons, + hits_count: hits.length, + hits, + }, null, 2)); + } else { + console.log(''); + console.log('=== 弱口令存量账号扫描(只读)==='); + console.log(`数据库 : ${cfg.user}@${cfg.host}:${cfg.port}/${cfg.database}`); + console.log(`扫描范围 : ${opts.all ? '全部账号' : '仅 activated=1(已激活)'}`); + console.log(`账号行数 : ${total}(其中无口令 ${skipped} 行已跳过;bcrypt 哈希 ${bcryptRows} 行)`); + console.log(`候选口令 : ${candidates.length} 条 × ${total - skipped} 行 = ${comparisons} 次比对`); + console.log(''); + + if (hits.length === 0) { + console.log('✓ 零命中:没有任何账号的口令命中候选弱口令清单'); + console.log(' (本结论的覆盖面见上方「账号行数 / 候选口令」两行,非空输出)'); + } else { + console.log(`⚠️ 命中 ${hits.length} 个账号(口令为弱口令):`); + console.log(''); + console.log(' id 组别 测试? 已激活 姓名 身份证(脱敏) 口令 哈希'); + console.log(' ------ -------------- ------ ------ ---------------- -------------------- ----------- --------'); + for (const h of hits) { + console.log( + ' ' + + String(h.id).padEnd(7) + + String(h.group || '').padEnd(15) + + (h.test_flag ? '是 ' : '否 ') + + (h.activated ? '是 ' : '否 ') + + String(h.name || '').padEnd(17) + + String(h.id_number || '').padEnd(21) + + String(h.matched_password).padEnd(12) + + h.hash_type + ); + } + console.log(''); + console.log(' 判读提示(重要,勿误判):'); + console.log(' · test_flag=1 且口令=123456:通常是**设计内的测试便利口令**'); + console.log(' (config.default.js:153 testDefaultPassword;由 resetTestData / 测试数据导入直写库,'); + console.log(' 不经口令策略),**不是**被浏览器预填污染的考生账号。'); + console.log(' · 真正的风险信号 = **test_flag=0(正式考生)且口令命中弱口令清单**。'); + console.log(''); + console.log(' ⇒ 处置建议:重设其口令为随机强口令,并提示考生走「忘记密码」自行设置。'); + console.log(' ⚠️ 不要直接把口令告知考生本人以外的人;本表含 PII,勿外发。'); + } + console.log(''); + } + + return 0; + } finally { + await conn.end(); + } +} + +main() + .then(code => process.exit(code)) + .catch(e => { + console.error('✗ 扫描失败:' + e.message); + process.exit(2); + }); diff --git a/scripts/verify_password_policy_api.cjs b/scripts/verify_password_policy_api.cjs new file mode 100644 index 0000000..b686730 --- /dev/null +++ b/scripts/verify_password_policy_api.cjs @@ -0,0 +1,422 @@ +#!/usr/bin/env node +'use strict'; + +/** + * 口令策略 —— 真接口验证(R63 · C1) + * ============================================================ + * + * 目的:在**真实 HTTP 链路**上验证口令强度策略生效(单测只证明纯函数正确, + * 不能证明它被正确挂到了接口上;控制器里的调用点缺失,单测照样全绿)。 + * + * 判别力设计(**这是本脚本的核心价值**,勿简化) + * ------------------------------------------------------------ + * 对同一账号、同一 `smsCode`、同一接口路径,先提交**弱口令**、再提交**强口令**, + * 要求: + * · 弱口令 → `code = 2001`(activate)/ `7001`(reset-password) + * · 强口令 → `code = 0` + * **两者必须同时成立**才计通过。理由:如果链路本身坏了(例如短信码校验失败), + * 两个请求会**同时**返回 2005/7004 —— 那样"弱口令被拒"是**假绿**(它其实是被 + * 短信校验拦下的,与口令策略无关)。因此断言必须落在**具体的业务码**上, + * 且必须有"强口令通过"作为链路通畅的正向对照。 + * + * 数据安全(重要) + * ------------------------------------------------------------ + * 本脚本会**真实写库**(激活账号 / 改口令),因此: + * · 只操作**测试库**。脚本启动时若检测到 URL 指向非本机,要求显式 `--allow-remote`。 + * · 写前对每个受影响账号做**快照**(activated + password 原值)。 + * · `finally` 中**无条件恢复**,并用**第二条独立连接**复核恢复结果 + * (不依赖同连接的可见性语义)。 + * + * 用法 + * ------------------------------------------------------------ + * node backend/scripts/verify_password_policy_api.cjs + * API_PORT=7001 API_HOST=127.0.0.1 node backend/scripts/verify_password_policy_api.cjs + * + * 退出码 + * 0 = 全部通过;1 = 存在 FAIL;2 = 环境错误(无可用账号 / 连不上后端) + */ + +const http = require('http'); +const mysql = require('mysql2/promise'); +const bcrypt = require('bcrypt'); +const { loadDbConfig } = require('./_db.cjs'); + +const API_HOST = process.env.API_HOST || '127.0.0.1'; +const API_PORT = parseInt(process.env.API_PORT || '7001', 10); + +/** 弱口令样本(覆盖策略的各条规则) */ +const WEAK_SAMPLES = [ + { pwd: '123456', note: '黑名单命中(R61 实测的浏览器预填值)' }, + { pwd: 'aaaaaa', note: '纯重复字符' }, + { pwd: 'abcdef', note: '整串连续序列' }, + { pwd: 'password', note: '英文弱口令' }, +]; + +/** 强口令样本(保证能通过策略) */ +const STRONG = 'Zx9#kQ2m'; +const STRONG2 = 'Qw8@zL4n'; + +// ── 断言 ──────────────────────────────────────────────────── +let pass = 0; +const failures = []; + +function ok(name, cond, detail) { + if (cond) { + pass++; + console.log(` ✓ ${name}`); + } else { + failures.push(`${name} —— ${detail}`); + console.log(` ✗ ${name} ${detail}`); + } +} + +// ── 极简 HTTP 客户端(带 cookie 罐;Egg session 依赖 EGG_SESS cookie)── +class ApiClient { + constructor() { + this.cookie = ''; + } + + _mergeCookies(setCookie) { + if (!setCookie || !setCookie.length) return; + const jar = {}; + for (const part of String(this.cookie).split('; ')) { + if (!part) continue; + const i = part.indexOf('='); + if (i > 0) jar[part.slice(0, i)] = part.slice(i + 1); + } + for (const sc of setCookie) { + const kv = String(sc).split(';')[0]; + const i = kv.indexOf('='); + if (i > 0) jar[kv.slice(0, i)] = kv.slice(i + 1); + } + this.cookie = Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; '); + } + + request(method, path, body) { + return new Promise((resolve, reject) => { + const data = body === undefined ? null : JSON.stringify(body); + const headers = { 'Content-Type': 'application/json' }; + if (data) headers['Content-Length'] = Buffer.byteLength(data); + if (this.cookie) headers.Cookie = this.cookie; + + const req = http.request({ host: API_HOST, port: API_PORT, path, method, headers }, res => { + let buf = ''; + res.setEncoding('utf8'); + res.on('data', c => { buf += c; }); + res.on('end', () => { + this._mergeCookies(res.headers['set-cookie']); + let json = null; + try { json = JSON.parse(buf); } catch (e) { /* 非 JSON(如 SVG / 500 页面) */ } + resolve({ status: res.statusCode, json, raw: buf }); + }); + }); + req.on('error', reject); + if (data) req.write(data); + req.end(); + }); + } + + get(p) { return this.request('GET', p); } + post(p, body) { return this.request('POST', p, body); } +} + +/** + * 解析图形验证码的点击顺序。 + * + * `GET/POST /api/auth/captcha` 返回 `{chars: [...打乱], hint: '请依次点击:天 → 地 → 人 → 和'}`, + * 而服务端 session 存的是 `selected.join('')`(**未打乱**的顺序)。 + * ⇒ 从 hint 里按 "→" 拆开再拼起来,恰好等于服务端存的正确答案。 + * (这是测试环境可用的快速通道;生产前端靠用户点击。) + * + * @param {string} hint + * @returns {string|null} + */ +function parseClickOrder(hint) { + const m = String(hint || '').match(/请依次点击\s*[::]\s*(.+)$/); + if (!m) return null; + const parts = m[1].split(/\s*→\s*/).map(s => s.trim()).filter(Boolean); + if (!parts.length) return null; + return parts.join(''); +} + +/** + * 走完「图形验证码 → 短信验证码」两步,返回 smsCode。 + * @param {ApiClient} client + * @param {string} idNumber 身份证号(sms 接口的 phone 参数实际填身份证号) + * @param {string} label 场景标签(用于断言命名) + */ +async function fetchSmsCode(client, idNumber, label, endpoint) { + const cap = await client.post('/api/auth/captcha', {}); + const order = parseClickOrder(cap.json && cap.json.data && cap.json.data.hint); + if (!order) { + return { err: `无法解析图形验证码 hint(status=${cap.status})` }; + } + const sms = await client.post(endpoint, { phone: idNumber, captcha: order, id_number: idNumber }); + const j = sms.json || {}; + if (j.code !== 0) { + return { err: `${label} 取码失败:code=${j.code} message=${j.message}` }; + } + return { + smsCode: j.data && j.data.smsCode, + realPhone: j.data && j.data.phone, + hint: cap.json.data.hint, + }; +} + +async function main() { + console.log(''); + console.log('=== 口令策略真接口验证(R63 · C1)==='); + console.log(`后端: http://${API_HOST}:${API_PORT}`); + console.log(''); + + // 保险:只允许对本机后端做"会写库"的验证 + if (API_HOST !== '127.0.0.1' && API_HOST !== 'localhost' && !process.argv.includes('--allow-remote')) { + console.log(`✗ 拒绝执行:API_HOST=${API_HOST} 非本机。本脚本会真实写库,`); + console.log(' 如确需对远端执行请显式加 --allow-remote(并确认那是测试库)。'); + return 2; + } + + const cfg = loadDbConfig(); + const conn = await mysql.createConnection(cfg); + + /** 受影响账号快照,用于 finally 恢复 */ + const backups = []; + /** + * 退出码。 + * ⚠️ 刻意**不用** `return expr`:`return` 的表达式在 finally **之前**求值, + * 而"账号恢复失败"是在 finally 内部才发现的 —— 用 return 会静默漏掉它。 + */ + let exitCode = 1; + + try { + // ── 健康检查 ──────────────────────────────────── + const hc = new ApiClient(); + const health = await hc.get('/api/health'); + const healthOk = health.json && health.json.code === 0; + ok('后端可达且 /api/health code=0', healthOk, `status=${health.status} body=${health.raw.slice(0, 120)}`); + if (!healthOk) { + console.log(' ⇒ 后端不可用,后续用例无意义,直接结束'); + return 2; + } + + // ════════════════════════════════════════════════ + // 通道 A:POST /api/auth/activate + // ════════════════════════════════════════════════ + console.log(''); + console.log('[通道 A] POST /api/auth/activate(短信激活,设置初始口令)'); + + const [pendingRows] = await conn.query( + "SELECT id, id_number, phone, activated, password FROM contestants " + + "WHERE activated = 0 AND phone IS NOT NULL AND phone <> '' ORDER BY id LIMIT 2" + ); + const pending = pendingRows[0]; + + if (!pending) { + console.log(' ⚠️ 库中没有「未激活且有手机号」的账号,跳过通道 A'); + console.log(' (如需验证,请先把某个测试账号置为 activated=0)'); + } else { + backups.push({ id: pending.id, activated: pending.activated, password: pending.password, tag: 'A' }); + + const cA = new ApiClient(); + const smsInfo = await fetchSmsCode(cA, pending.id_number, 'activate', '/api/auth/sms'); + ok('A1 图形验证码 + 短信验证码链路可用', !smsInfo.err, smsInfo.err || ''); + if (!smsInfo.err) { + ok('A2 测试环境回传 smsCode(非生产模式)', !!smsInfo.smsCode, + `data=${JSON.stringify(smsInfo).slice(0, 160)}`); + + // A3-A6:弱口令样本逐一被拒,且必须是「策略码 2001」而非其它失败 + for (const s of WEAK_SAMPLES) { + // eslint-disable-next-line no-await-in-loop + const r = await cA.post('/api/auth/activate', { + id_number: pending.id_number, + phone: smsInfo.realPhone, + smsCode: smsInfo.smsCode, + password: s.pwd, + }); + const code = r.json && r.json.code; + ok(`A·弱口令被拒 code=2001 → ${JSON.stringify(s.pwd)}(${s.note})`, + code === 2001, + `实际 code=${code} message=${r.json && r.json.message}`); + } + + // A7:21 位 —— 由 validate 的 max 拦下(400 参数校验失败),不是策略码 + { + const longPwd = 'Zx9#kQ2mNp5$vLr8Tb1!a'; // 21 位 + const r = await cA.post('/api/auth/activate', { + id_number: pending.id_number, + phone: smsInfo.realPhone, + smsCode: smsInfo.smsCode, + password: longPwd, + }); + const code = r.json && r.json.code; + ok('A7 超长口令(21 位)被 validate 拒绝(code=400)', code === 400, + `实际 code=${code} message=${r.json && r.json.message}`); + } + + // A8:强口令通过(正向对照 —— 证明链路本身通畅,否则前面的"被拒"是假绿) + { + const r = await cA.post('/api/auth/activate', { + id_number: pending.id_number, + phone: smsInfo.realPhone, + smsCode: smsInfo.smsCode, + password: STRONG, + }); + const code = r.json && r.json.code; + ok(`A8 强口令通过(code=0)—— 正向对照,证明 A·弱口令被拒非假绿`, code === 0, + `实际 code=${code} message=${r.json && r.json.message}`); + } + + // A9-A10:落库核对(真写进去了,且写的是强口令) + { + const [after] = await conn.query( + 'SELECT activated, password FROM contestants WHERE id = ?', [pending.id] + ); + const row = after[0] || {}; + ok('A9 DB 已激活(activated=1)', Number(row.activated) === 1, + `实际 activated=${row.activated}`); + // eslint-disable-next-line no-await-in-loop + const matched = row.password ? await bcrypt.compare(STRONG, row.password) : false; + ok('A10 DB 中口令哈希与强口令匹配(bcrypt 比对)', matched, + '哈希与强口令不匹配,说明落库内容并非提交的强口令'); + } + } + } + + // ════════════════════════════════════════════════ + // 通道 B:POST /api/auth/reset-password + // ════════════════════════════════════════════════ + console.log(''); + console.log('[通道 B] POST /api/auth/reset-password(忘记密码,重设口令)'); + + const target = pending || pendingRows[1]; + if (!target) { + console.log(' ⚠️ 没有可用账号,跳过通道 B'); + } else { + // 若该账号尚未在快照中(通道 A 跳过的情形),补一条 + if (!backups.some(b => b.id === target.id)) { + const [rs] = await conn.query( + 'SELECT id, id_number, phone, activated, password FROM contestants WHERE id = ?', [target.id] + ); + if (rs[0]) backups.push({ id: rs[0].id, activated: rs[0].activated, password: rs[0].password, tag: 'B' }); + } + // ⚠️ 必须**实时查库**判断激活态:`target` 是通道 A 开始前的内存快照, + // 不会随通道 A 的激活动作更新 —— 用它判断会得出"仍未激活"的错误结论 + // (曾因此重复走一遍激活、多发一条短信,且日志内容与事实相反、误导排障)。 + const [tRows] = await conn.query( + 'SELECT activated FROM contestants WHERE id = ?', [target.id] + ); + const tActivated = Number((tRows[0] || {}).activated) === 1; + if (!tActivated) { + // 通道 B 要求账号已激活(forgotPassword 会拦未激活账号,报 6004) + console.log(` ℹ️ 账号 id=${target.id} 当前未激活,先激活以便测试重置通道`); + const cAct = new ApiClient(); + const si = await fetchSmsCode(cAct, target.id_number, 'pre-activate', '/api/auth/sms'); + if (!si.err) { + // eslint-disable-next-line no-await-in-loop + await cAct.post('/api/auth/activate', { + id_number: target.id_number, phone: si.realPhone, smsCode: si.smsCode, password: STRONG, + }); + } + } + + const cB = new ApiClient(); + const smsB = await fetchSmsCode(cB, target.id_number, 'forgot-password', '/api/auth/forgot-password'); + ok('B1 忘记密码取码链路可用', !smsB.err, smsB.err || ''); + if (!smsB.err) { + for (const s of WEAK_SAMPLES) { + // eslint-disable-next-line no-await-in-loop + const r = await cB.post('/api/auth/reset-password', { + id_number: target.id_number, + smsCode: smsB.smsCode, + password: s.pwd, + }); + const code = r.json && r.json.code; + ok(`B·弱口令被拒 code=7001 → ${JSON.stringify(s.pwd)}(${s.note})`, + code === 7001, + `实际 code=${code} message=${r.json && r.json.message}`); + } + + { + const r = await cB.post('/api/auth/reset-password', { + id_number: target.id_number, + smsCode: smsB.smsCode, + password: STRONG2, + }); + const code = r.json && r.json.code; + ok('B2 强口令通过(code=0)—— 正向对照', code === 0, + `实际 code=${code} message=${r.json && r.json.message}`); + } + + { + const [after] = await conn.query( + 'SELECT password FROM contestants WHERE id = ?', [target.id] + ); + const row = after[0] || {}; + // eslint-disable-next-line no-await-in-loop + const matched = row.password ? await bcrypt.compare(STRONG2, row.password) : false; + ok('B3 DB 中口令哈希与新口令匹配(bcrypt 比对)', matched, '哈希与新口令不匹配'); + } + } + } + + exitCode = failures.length === 0 ? 0 : 1; + } finally { + // ── 无条件的账号恢复 ────────────────────────────── + console.log(''); + console.log('[收尾] 恢复被本脚本改动的账号'); + if (!backups.length) { + console.log(' ℹ️ 无账号被改动,无需恢复'); + } else { + for (const b of backups) { + // eslint-disable-next-line no-await-in-loop + await conn.query( + 'UPDATE contestants SET activated = ?, password = ? WHERE id = ?', + [b.activated, b.password, b.id] + ); + } + // 用**第二条独立连接**复核(不依赖同连接可见性语义;项目 R27 同类教训) + const verifyConn = await mysql.createConnection(cfg); + try { + let allOk = true; + for (const b of backups) { + // eslint-disable-next-line no-await-in-loop + const [vr] = await verifyConn.query( + 'SELECT activated, password FROM contestants WHERE id = ?', [b.id] + ); + const row = vr[0] || {}; + const same = Number(row.activated) === Number(b.activated) + && String(row.password) === String(b.password); + if (!same) { + allOk = false; + failures.push(`恢复失败 id=${b.id}(通道 ${b.tag})`); + } + } + ok(`已恢复 ${backups.length} 个账号并独立复核一致`, allOk, + '存在未恢复一致的账号,请人工核查'); + } finally { + await verifyConn.end(); + } + } + console.log(''); + console.log('────────────────────────────────────────'); + if (failures.length === 0) { + console.log(`✓ 全部通过:${pass} 项`); + } else { + console.log(`✗ ${failures.length} 项失败 / 共 ${pass + failures.length} 项`); + for (const f of failures) console.log(` · ${f}`); + } + console.log(''); + await conn.end(); + // 恢复阶段新增的失败也必须反映到退出码(见 exitCode 处的注释) + if (failures.length > 0) exitCode = 1; + } + + return exitCode; +} + +main() + .then(code => process.exit(code)) + .catch(e => { + console.error('✗ 执行异常:' + (e && e.stack || e)); + process.exit(2); + });