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:
61
scripts/_db.cjs
Normal file
61
scripts/_db.cjs
Normal file
@@ -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 };
|
||||
274
scripts/scan_weak_password_accounts.cjs
Normal file
274
scripts/scan_weak_password_accounts.cjs
Normal file
@@ -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<string|null>}
|
||||
*/
|
||||
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 <pwd> 追加自定义候选口令(可重复)
|
||||
--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);
|
||||
});
|
||||
422
scripts/verify_password_policy_api.cjs
Normal file
422
scripts/verify_password_policy_api.cjs
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user