Files
competition-website/scripts/verify_password_policy_api.cjs

423 lines
18 KiB
JavaScript
Raw Permalink Normal View History

#!/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);
});