Files
competition-website/scripts/verify_password_policy_api.cjs
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

423 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
#!/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);
});