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:
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);
|
||||
});
|
||||
Reference in New Issue
Block a user