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