Files
tamakyi 752fad412b 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 项全部通过
2026-10-14 12:37:07 +08:00

264 lines
9.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 管理端鉴权: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;
}