feat: v0.12.0 —— 表格每行绝对等高 + 管理 WebUI(2FA 绑定才能进入)

① 每行绝对等高
- 行高与内容长度彻底解耦:新增 --row-h: 72px 作为唯一基准
- 名称列改为「文本区限两行 + 徽标区固定单行」两段式;文本区用 .clamp2
- 关键点:必须用 height 而非 max-height —— 后者会让短内容缩成一行、
  长内容撑到两行,反而产生 66px/71px 两种行高(实测踩过)
- 分组标题行用 :not(.group-row) 排除在数据行规则之外
- 事件表与监控表格共用同一个 --row-h,两张表视觉上也严格对齐
- 完整值仍可通过悬停 title 查看(已加断言强制所有截断单元格带 title)
- 卡片视图同样等高(13 视口 × 250px 全一致)

② 管理 WebUI + 2FA
- 新增 /admin 与 /api/admin/*;未绑定 2FA 时除绑定流程外一律 403
- 零依赖实现 TOTP(RFC 6238),用 RFC 4226/6238 官方测试向量验证(16 个向量)
- 内置 QR 码生成器(byte 模式 / ECC L / 版本 1-10,GF(256) RS 纠错 + 掩码评分),
  用独立解码器实测:版本 1 与版本 6 均能读出逐字节一致的内容
- 绑定流程:口令 → 生成密钥(扫码/手输/otpauth 链接)→ 验证 6 位码 → 10 枚恢复码;
  完成后凭本次验证码直接换取会话
- 安全:会话 id 32 字节随机数且服务端只存哈希;HttpOnly+SameSite=Strict;
  30 分钟空闲/2 小时最长;同时间步的码防重放;恢复码一次性;
  登录失败按 IP 指数退避锁定;不解析 X-Forwarded-For
- 管理功能:运行状态、探测范围、展示口径、维护窗口增删、立即重探、通知自检、
  目标只读列表、最近事件
- 所有写操作只落 local.monitors.json(私有不入库),绝不改 public/monitors.json

过程中修掉三个真 bug:
1. 会话 cookie 的 Path=/admin 覆盖不到 /api/admin/*,登录 200 但后续全部 401
2. 恢复码登录被 verifyTotp 的 invalid-format 提前挡掉(恢复码非 6 位数字)
3. 超大请求体用 req.destroy() 硬断连接,客户端只看到 fetch failed 拿不到状态码

测试:新增 scripts/verify-admin.cjs(102 项),全项目共 7 套 274 项全部通过
This commit is contained in:
tamakyi
2026-10-14 12:37:07 +08:00
parent a3a683ff0e
commit 752fad412b
20 changed files with 2631 additions and 339 deletions
+131
View File
@@ -0,0 +1,131 @@
/**
* TOTP(RFC 6238)+ Base32,零依赖实现
*
* 只用 node:crypto 的 HMAC,不引第三方库。验证器 App(Google Authenticator /
* Microsoft Authenticator / 1Password / Aegis 等)都用同一套算法。
*
* 参考:RFC 6238 TOTP、RFC 4226 HOTP、RFC 3548 Base32。
*/
import crypto from 'node:crypto';
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/* ---------- Base32 ---------- */
export function base32Encode(buf) {
let bits = 0;
let value = 0;
let out = '';
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31];
return out;
}
// 容忍用户输入里的小写、空格与连字符(很多 App 展示密钥时会分组)
export function base32Decode(str) {
const clean = String(str ?? '').toUpperCase().replace(/[\s-]/g, '');
let bits = 0;
let value = 0;
const out = [];
for (const ch of clean) {
const idx = BASE32_ALPHABET.indexOf(ch);
if (idx < 0) continue; // 跳过无法识别的字符(如误粘贴的 '0'/'1'/'8'/'9')
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(out);
}
/* ---------- 密钥 ---------- */
// 20 字节 = 160 位,符合 RFC 4226 对 HMAC-SHA1 的建议强度
export function generateSecret(bytes = 20) {
return base32Encode(crypto.randomBytes(bytes));
}
/* ---------- TOTP ---------- */
export const DEFAULT_DIGITS = 6;
export const DEFAULT_PERIOD_SEC = 30;
// 允许 ±1 个时间窗:手机时间与服务端有几秒偏差是常态,也容忍一次网络延迟
export const DEFAULT_WINDOW = 1;
export function hotp(secretBuf, counter, digits = DEFAULT_DIGITS) {
const msg = Buffer.alloc(8);
msg.writeBigUInt64BE(BigInt(counter));
const hmac = crypto.createHmac('sha1', secretBuf).update(msg).digest();
// RFC 4226 动态截断:取最后一个字节的低 4 位作偏移
const offset = hmac[hmac.length - 1] & 0x0f;
const code = ((hmac[offset] & 0x7f) << 24) | (hmac[offset + 1] << 16) | (hmac[offset + 2] << 8) | hmac[offset + 3];
return String(code % 10 ** digits).padStart(digits, '0');
}
export function totp(secretBuf, { atMs = Date.now(), periodSec = DEFAULT_PERIOD_SEC, digits = DEFAULT_DIGITS } = {}) {
return hotp(secretBuf, Math.floor(atMs / 1000 / periodSec), digits);
}
/**
* 校验用户输入的验证码。
* 返回 null 表示通过;返回字符串表示拒绝原因(供接口回显,不泄露密钥相关信息)。
*/
export function verifyTotp(secret, code, { atMs = Date.now(), periodSec = DEFAULT_PERIOD_SEC, digits = DEFAULT_DIGITS, window = DEFAULT_WINDOW } = {}) {
const clean = String(code ?? '').replace(/\s|-/g, '');
if (!new RegExp(`^\\d{${digits}}$`).test(clean)) return 'invalid-format';
const secretBuf = base32Decode(secret);
if (secretBuf.length < 10) return 'invalid-secret';
const counter = Math.floor(atMs / 1000 / periodSec);
// 用定长比较,避免逐字符提前返回带来的时序侧信道
const a = Buffer.from(clean, 'utf8');
for (let w = -window; w <= window; w++) {
const expect = Buffer.from(hotp(secretBuf, counter + w, digits), 'utf8');
if (expect.length === a.length && crypto.timingSafeEqual(expect, a)) return null;
}
return 'invalid-code';
}
// 当前验证码所属的时间步:用于「同一步内不得重复使用」的防重放
export function totpCounter({ atMs = Date.now(), periodSec = DEFAULT_PERIOD_SEC } = {}) {
return Math.floor(atMs / 1000 / periodSec);
}
/* ---------- otpauth URI(给验证器扫码用) ---------- */
export function otpauthUri({ secret, label, issuer, digits = DEFAULT_DIGITS, periodSec = DEFAULT_PERIOD_SEC }) {
const q = new URLSearchParams({ secret, digits: String(digits), period: String(periodSec) });
// issuer 同时出现在路径与查询参数里:兼容各家 App 的两种解析方式
if (issuer) q.set('issuer', issuer);
return `otpauth://totp/${encodeURIComponent(issuer ?? label)}:${encodeURIComponent(label)}?${q}`;
}
/* ---------- 恢复码 ---------- */
// 每个码 10 字符,形如 X7K2-9QMD4;只存哈希,明文只在绑定成功时展示一次
export function generateRecoveryCodes(count = 10) {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 去掉易混淆的 0/O/1/I
const codes = [];
for (let i = 0; i < count; i++) {
let s = '';
const bytes = crypto.randomBytes(10);
for (let j = 0; j < 10; j++) s += alphabet[bytes[j] % alphabet.length];
codes.push(`${s.slice(0, 5)}-${s.slice(5)}`);
}
return codes;
}
// 归一化后再哈希:用户从别处抄码常漏掉连字符或用小写,两种都要能登录
export function hashRecoveryCode(code, salt) {
const norm = String(code ?? '').toUpperCase().replace(/[\s-]/g, '');
return crypto.createHash('sha256').update(`${salt}:${norm}`).digest('hex');
}