① 每行绝对等高 - 行高与内容长度彻底解耦:新增 --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 项全部通过
264 lines
9.3 KiB
JavaScript
264 lines
9.3 KiB
JavaScript
/**
|
||
* 管理端鉴权: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;
|
||
} |