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
+264
View File
@@ -0,0 +1,264 @@
/**
* 管理端鉴权:2FA 绑定、登录、会话、失败限流
*
* 安全约束(与项目的零依赖、一切自建原则一致):
* · 未绑定 2FA 时,任何管理接口都不可用 —— 绑定动作本身也需要一次性 setup key
* · 会话 id 用 32 字节随机数,服务端只存哈希(内存态,进程重启即全部失效)
* · 登录失败按 IP 计数,超过阈值指数退避,避免在线爆破 6 位码
* · 恢复码一次性使用,用后立刻作废并落盘
* · 管理写操作一律落 local.monitors.json(私有、不入库),不污染 public 配置
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import {
DEFAULT_DIGITS,
DEFAULT_PERIOD_SEC,
generateRecoveryCodes,
generateSecret,
hashRecoveryCode,
otpauthUri,
totpCounter,
verifyTotp,
} from './totp.js';
const SESSION_TTL_MS = 2 * 60 * 60 * 1000; // 会话最长 2 小时
const SESSION_IDLE_MS = 30 * 60 * 1000; // 30 分钟无操作即失效
const MAX_FAILS = 5;
const LOCK_BASE_MS = 60 * 1000; // 首次锁定 1 分钟,之后翻倍
const LOCK_MAX_MS = 30 * 60 * 1000;
const SETUP_TTL_MS = 10 * 60 * 1000; // 未完成的绑定流程 10 分钟作废
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
const token = (bytes = 32) => crypto.randomBytes(bytes).toString('base64url');
/**
* @param {object} opts
* @param {string} opts.storePath 2FA 档案落盘位置(data/admin-2fa.json,已 gitignore)
* @param {string} opts.setupKey 环境变量 SHIROKO_ADMIN_SETUP_KEY 提供的绑定口令
* @param {() => number} [opts.now] 便于测试注入时间
*/
export function createAdminAuth({ storePath, setupKey = '', now = Date.now }) {
let store = load();
const sessions = new Map(); // hash(sessionId) -> { createdAt, lastSeenAt, ip }
const fails = new Map(); // ip -> { count, until }
let setup = null; // { key, secret, createdAt, ip } —— 绑定过程中的临时态
function load() {
try {
return JSON.parse(fs.readFileSync(storePath, 'utf8'));
} catch {
return { bound: false };
}
}
function save() {
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`);
}
function isBound() {
return store.bound === true && typeof store.secret === 'string' && store.secret.length > 0;
}
/* ---------- 绑定 ---------- */
function configuredSetupKey() {
return String(setupKey || process.env.SHIROKO_ADMIN_SETUP_KEY || '').trim();
}
/**
* 第一步:提交绑定口令,换取一个待确认的 secret。
* 口令从环境变量读取且只做定长比较,服务端不保存它的副本。
*/
function beginSetup(inputKey, ip) {
if (isBound()) return { ok: false, error: 'already-bound' };
const want = configuredSetupKey();
if (!want) return { ok: false, error: 'setup-disabled' };
if (!safeEqual(String(inputKey ?? ''), want)) return { ok: false, error: 'bad-setup-key' };
setup = { key: token(24), secret: generateSecret(), createdAt: now(), ip };
const label = 'shiroko-monitor';
return {
ok: true,
setupToken: setup.key,
secret: setup.secret,
uri: otpauthUri({ secret: setup.secret, label, issuer: label }),
};
}
/**
* 第二步:验证器 App 里能出码即证明绑定成功,落盘 secret 并给出一批恢复码。
*/
function confirmSetup(setupTokenValue, code) {
if (isBound()) return { ok: false, error: 'already-bound' };
if (!setup || setup.key !== String(setupTokenValue ?? '')) return { ok: false, error: 'no-setup' };
if (now() - setup.createdAt > SETUP_TTL_MS) { setup = null; return { ok: false, error: 'setup-expired' }; }
const reason = verifyTotp(setup.secret, code, { atMs: now(), digits: DEFAULT_DIGITS, periodSec: DEFAULT_PERIOD_SEC });
if (reason) return { ok: false, error: reason };
const salt = token(16);
const recoveryCodes = generateRecoveryCodes();
store = {
bound: true,
secret: setup.secret,
salt,
recovery: recoveryCodes.map((c) => hashRecoveryCode(c, salt)),
digits: DEFAULT_DIGITS,
periodSec: DEFAULT_PERIOD_SEC,
issuer: 'shiroko-monitor',
boundAt: new Date(now()).toISOString(),
lastUsedCounter: null,
};
setup = null;
save();
return { ok: true, recoveryCodes };
}
/* ---------- 登录 ---------- */
function lockState(ip) {
const rec = fails.get(ip);
if (!rec) return { locked: false, waitMs: 0 };
if (rec.until && rec.until > now()) return { locked: true, waitMs: rec.until - now() };
if (rec.until && rec.until <= now()) { rec.until = 0; rec.count = 0; }
return { locked: false, waitMs: 0 };
}
function noteFail(ip) {
const rec = fails.get(ip) ?? { count: 0, until: 0 };
rec.count += 1;
if (rec.count >= MAX_FAILS) {
const mult = 2 ** Math.min(rec.count - MAX_FAILS, 5); // 1m → 2m → 4m … 上限 30m
rec.until = now() + Math.min(LOCK_BASE_MS * mult, LOCK_MAX_MS);
}
fails.set(ip, rec);
return rec;
}
function noteSuccess(ip) {
fails.delete(ip);
}
function consumeRecovery(inputCode) {
const code = String(inputCode ?? '').trim().toUpperCase();
const hash = hashRecoveryCode(code, store.salt);
const idx = store.recovery.indexOf(hash);
if (idx < 0) return false;
store.recovery.splice(idx, 1); // 一次性
save();
return true;
}
/**
* 登录:6 位 TOTP 码,或任一枚未使用的恢复码。
* 通过后签发会话 id(明文只在这一刻返回一次,服务端只留哈希)。
*/
function login(inputCode, ip) {
if (!isBound()) return { ok: false, error: 'not-bound' };
const lock = lockState(ip);
if (lock.locked) return { ok: false, error: 'locked', retryAfter: Math.ceil(lock.waitMs / 1000) };
const digits = store.digits ?? DEFAULT_DIGITS;
const periodSec = store.periodSec ?? DEFAULT_PERIOD_SEC;
const reason = verifyTotp(store.secret, inputCode, { atMs: now(), digits, periodSec });
let via = null;
let replay = false;
if (reason === null) {
// 防重放:同一时间步的码用过即作废,防止截图被二次使用
const counter = totpCounter({ atMs: now(), periodSec });
if (store.lastUsedCounter != null && counter <= store.lastUsedCounter) replay = true;
else via = 'totp';
} else if (consumeRecovery(inputCode)) {
// 恢复码兜底:手机丢了、验证器清库了仍可进入。
// 恢复码是 10 位字母数字,会先被 verifyTotp 判为 invalid-format,属正常路径。
via = 'recovery';
}
if (!via) {
const rec = noteFail(ip);
// 回同一个错误码,不告诉攻击者「格式不对」还是「值不对」
return { ok: false, error: replay ? 'replayed' : 'invalid-code', fails: rec.count, locked: rec.until > now() };
}
if (via === 'totp') {
store.lastUsedCounter = totpCounter({ atMs: now(), periodSec });
save();
}
noteSuccess(ip);
return { ok: true, sessionId: issue(ip), via };
}
/* ---------- 会话 ---------- */
function issue(ip) {
const id = token(32);
sessions.set(sha256(id), { createdAt: now(), lastSeenAt: now(), ip });
return id;
}
/** 校验并续期;返回会话对象或 null(同时清理过期项) */
function verifySession(sessionId) {
if (!sessionId) return null;
const key = sha256(sessionId);
const s = sessions.get(key);
if (!s) return null;
const t = now();
if (t - s.createdAt > SESSION_TTL_MS || t - s.lastSeenAt > SESSION_IDLE_MS) {
sessions.delete(key);
return null;
}
s.lastSeenAt = t;
return s;
}
function destroySession(sessionId) {
if (sessionId) sessions.delete(sha256(sessionId));
}
function activeSessions() {
const t = now();
return [...sessions.values()].filter((s) => t - s.createdAt <= SESSION_TTL_MS && t - s.lastSeenAt <= SESSION_IDLE_MS).length;
}
/** 重置 2FA(丢失全部验证码时的逃生口,需提供 setup key) */
function reset(inputKey) {
const want = configuredSetupKey();
if (!want || !safeEqual(String(inputKey ?? ''), want)) return { ok: false, error: 'bad-setup-key' };
store = { bound: false };
sessions.clear();
fails.clear();
setup = null;
save();
return { ok: true };
}
function status() {
return {
bound: isBound(),
boundAt: store.boundAt ?? null,
recoveryLeft: Array.isArray(store.recovery) ? store.recovery.length : 0,
sessions: activeSessions(),
// 恢复码用量超过一半时提示重新生成,避免某天发现全用完了
recoveryLow: isBound() && Array.isArray(store.recovery) && store.recovery.length <= 3,
};
}
return {
isBound, beginSetup, confirmSetup, login, verifySession, destroySession,
activeSessions, reset, status, lockState,
logoutAll: () => sessions.clear(),
};
}
function safeEqual(a, b) {
const x = Buffer.from(String(a), 'utf8');
const y = Buffer.from(String(b), 'utf8');
// 长度不等时也走一次比较,避免通过响应时间推断口令长度
const pad = Math.max(x.length, y.length);
const xa = Buffer.alloc(pad);
const ya = Buffer.alloc(pad);
x.copy(xa);
y.copy(ya);
return crypto.timingSafeEqual(xa, ya) && x.length === y.length;
}