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
+38 -1
View File
@@ -22,6 +22,8 @@ import { probeBanner } from './lib/banner-probe.js';
import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js';
import { notifyAll, sendTestNotifications, createMeoWLimiter } from './lib/notify.js';
import { probeCertificate, tlsTargetOf } from './lib/tls-probe.js';
import { createAdminAuth } from './lib/admin.js';
import { createAdminRouter } from './lib/admin-routes.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..');
@@ -37,7 +39,7 @@ const CONCURRENCY = 8;
const WRITE_DEBOUNCE_MS = 300;
const DEFAULT_INTERVAL_SEC = 60;
const PORT = Number(process.env.PORT || 8787);
const VERSION = '0.11.2';
const VERSION = '0.12.0';
const UR_DEFAULT_POLL_SEC = 300;
const UR_MIN_REFRESH_GAP_MS = 60000;
@@ -405,6 +407,8 @@ function writeJson(file, data) {
const NOTIFY_STATE_PATH = () => path.join(DATA_DIR, 'notify-state.json');
const EVENTS_PATH = () => path.join(DATA_DIR, 'events.json');
// 2FA 档案:与通知状态同放私有数据目录(data/ 已在 .gitignore 中)
const ADMIN_2FA_PATH = () => path.join(DATA_DIR, 'admin-2fa.json');
function monitorNotifyState(runtime, id) {
const list = runtime.notifyState.monitors;
@@ -930,10 +934,43 @@ function toUptimeRobotResponse(runtime) {
// runtime 是持有器对象:热重载只更新其属性,服务器与巡检循环始终读到最新引用
function createServer(runtime) {
const adminAuth = createAdminAuth({ storePath: ADMIN_2FA_PATH() });
// 私有层被管理端改写后要立刻生效:沿用与 fs.watch 热重载相同的复用逻辑,保留内存态
const reloadConfig = () => {
const next = loadConfig();
runtime.state = hydrate(next, runtime.state);
runtime.config = next;
};
const handleAdminApi = createAdminRouter({
auth: adminAuth,
localConfigPath: ACTIVE_LOCAL_CONFIG_PATH,
reloadConfig,
runtime,
fns: {
snapshot,
dataFilter,
probeScopeSetting,
localProbeTargets,
notifyConfig,
tlsConfig,
notifyLastResult: () => snapshot(runtime).notify ?? null,
forceRound,
scheduleWrite,
forcedMaxWaitMs: FORCED_MAX_WAIT_MS,
version: VERSION,
},
});
return http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const { config, state } = runtime;
try {
if (url.pathname.startsWith('/api/admin/')) return await handleAdminApi(req, res, url);
if (url.pathname === '/admin' || url.pathname === '/admin/') {
const file = path.join(PUBLIC_DIR, 'admin.html');
res.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-store' });
return void fs.createReadStream(file).pipe(res);
}
if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime));
if (url.pathname === '/api/monitors') return sendJson(res, config);
if (url.pathname === '/api/refresh') {
+334
View File
@@ -0,0 +1,334 @@
/**
* 管理端 HTTP 接口(全部挂在 /api/admin/*)
*
* 鉴权模型:
* · 未绑定 2FA 时,除绑定流程外的所有接口一律 403
* · 绑定流程需要一次性口令 SHIROKO_ADMIN_SETUP_KEY(服务端只做定长比较,不落盘)
* · 绑定完成后,每次操作都必须带有效会话 cookie(sid)
* · 登录失败按 IP 计数并指数退避
*
* 所有写操作只落 local.monitors.json(私有、不入库),绝不改 public/monitors.json。
*/
import fs from 'node:fs';
import path from 'node:path';
const COOKIE_NAME = 'shiroko_admin';
const MAX_BODY = 64 * 1024; // 管理接口都是小 JSON,64KB 足够挡住滥用
const SETUP_KEY = () => String(process.env.SHIROKO_ADMIN_SETUP_KEY || '').trim();
function sendJson(res, data, { status = 200, headers = {} } = {}) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers });
res.end(JSON.stringify(data));
}
function readBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (c) => {
size += c.length;
if (size > MAX_BODY) {
// 不能 destroy 连接:客户端只会看到 fetch failed 而拿不到状态码。
// 停止累积并标记,等 end 事件按正常路径回 413。
req.__tooLarge = true;
return;
}
chunks.push(c);
});
req.on('end', () => {
if (req.__tooLarge) return reject(Object.assign(new Error('body-too-large'), { code: 'body-too-large' }));
if (!chunks.length) return resolve({});
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { reject(Object.assign(new Error('bad-json'), { code: 'bad-json' })); }
});
req.on('error', () => reject(Object.assign(new Error('aborted'), { code: 'aborted' })));
});
}
// 把 readBody 的失败翻译成明确的 4xx,而不是掉进 500 或让连接直接断开
async function readJsonBody(req, res) {
try {
return { ok: true, value: await readBody(req) };
} catch (err) {
const code = err.code;
if (code === 'body-too-large') sendJson(res, { error: 'body-too-large', hint: `请求体超过 ${MAX_BODY} 字节` }, { status: 413 });
else if (code === 'bad-json') sendJson(res, { error: 'bad-json' }, { status: 400 });
else sendJson(res, { error: 'bad-request' }, { status: 400 });
return { ok: false };
}
}
function parseCookies(header = '') {
const out = {};
for (const part of String(header).split(';')) {
const i = part.indexOf('=');
if (i < 0) continue;
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
}
return out;
}
// 只认 socket 对端地址:X-Forwarded-For 可被任意伪造
const clientIp = (req) => String(req.socket.remoteAddress ?? '').replace(/^::ffff:/i, '');
// Path 必须覆盖接口路径:会话 cookie 在 /admin 页面与 /api/admin/* 接口之间共用,
// 只写 Path=/admin 会导致接口请求不带 cookie(登录成功但随即 401)
const SESSION_COOKIE = (sid) => `${COOKIE_NAME}=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=7200`;
const CLEAR_COOKIE = () => `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
// 管理端的写操作统一落私有层:public/monitors.json 是会随仓库分发的公开文件,不该被接口改写
function savePrivateConfig(localPath, mutate) {
let current = {};
try { current = JSON.parse(fs.readFileSync(localPath, 'utf8')); } catch { /* 文件不存在则从空开始 */ }
const next = mutate(current) ?? current;
fs.mkdirSync(path.dirname(localPath), { recursive: true });
fs.writeFileSync(localPath, `${JSON.stringify(next, null, 2)}\n`);
return next;
}
/**
* @param {object} deps
* @param {object} deps.auth createAdminAuth 的返回值
* @param {string} deps.localConfigPath私有配置路径(管理端写入的唯一目标)
* @param {() => void} deps.reloadConfig 热重载配置(私有层改动后必须调用)
* @param {object} deps.runtime 探针运行态
* @param {object} deps.fns index.js 内部的配置/快照函数(避免循环依赖,显式注入)
*/
export function createAdminRouter({ auth, localConfigPath, reloadConfig, runtime, fns }) {
const { snapshot, dataFilter, probeScopeSetting, localProbeTargets, notifyConfig, tlsConfig, notifyLastResult, forceRound, scheduleWrite, forcedMaxWaitMs, version } = fns;
function requireBound(res) {
if (auth.isBound()) return true;
sendJson(res, { error: 'not-bound', hint: '需先在 /admin 完成 2FA 绑定' }, { status: 403 });
return false;
}
function requireSession(req, res) {
const sid = parseCookies(req.headers.cookie)[COOKIE_NAME];
if (!auth.verifySession(sid)) {
sendJson(res, { error: 'unauthorized' }, { status: 401, headers: { 'set-cookie': CLEAR_COOKIE() } });
return false;
}
return true;
}
function overview() {
const cfg = runtime.config;
const s = cfg.settings ?? {};
const snap = snapshot(runtime);
return {
version,
status: {
dataFilter: dataFilter(cfg),
probeScope: probeScopeSetting(cfg),
urEnabled: runtime.ur.enabled,
urOk: runtime.ur.ok,
urError: runtime.ur.error,
urCount: runtime.ur.count,
localCount: localProbeTargets(cfg).length,
configCount: cfg.monitors.length,
dataUpdatedAt: new Date(runtime.dataUpdatedAt).toISOString(),
},
stats: snap.stats,
notify: notifyLastResult(),
settings: {
dataFilter: dataFilter(cfg),
probeScope: probeScopeSetting(cfg),
refreshInterval: s.refreshInterval ?? null,
hideTargets: s.hideTargets === true,
notifyEnabled: notifyConfig(cfg).enabled,
tlsEnabled: tlsConfig(cfg).enabled,
},
maintenance: Array.isArray(s.maintenance) ? s.maintenance : [],
monitors: cfg.monitors.map((m) => ({
id: m.id,
name: m.name,
group: m.group ?? '未分组',
type: m.type,
target: m.url ?? (m.host ? `${m.host}${m.port ? `:${m.port}` : ''}` : ''),
interval: m.interval ?? null,
timeout: m.timeout ?? null,
})),
twofa: auth.status(),
events: [...(runtime.events ?? [])].reverse().slice(0, 50),
};
}
return async function handleAdminApi(req, res, url) {
const ip = clientIp(req);
const p = url.pathname;
/* ---- 2FA 状态:未登录也要能查(页面据此决定显示绑定还是登录) ---- */
if (p === '/api/admin/status' && req.method === 'GET') {
return sendJson(res, {
bound: auth.isBound(),
setupKeyConfigured: Boolean(SETUP_KEY()),
twofa: auth.status(),
});
}
/* ---- 绑定流程 ---- */
if (p === '/api/admin/2fa/setup' && req.method === 'POST') {
if (auth.isBound()) return sendJson(res, { error: 'already-bound' }, { status: 409 });
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const out = auth.beginSetup(body.key, ip);
if (!out.ok) return sendJson(res, { error: out.error }, { status: out.error === 'setup-disabled' ? 503 : 401 });
return sendJson(res, out);
}
if (p === '/api/admin/2fa/confirm' && req.method === 'POST') {
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const out = auth.confirmSetup(body.setupToken, body.code);
if (!out.ok) return sendJson(res, { error: out.error }, { status: 400 });
console.log(`[admin] 2FA 已绑定(来自 ${ip}),恢复码已生成,请妥善保存`);
// 回传刚才用过的那枚验证码:前端据此直接换取会话,省掉用户再输一遍
return sendJson(res, { ok: true, recoveryCodes: out.recoveryCodes, confirmCode: String(body.code ?? '').trim() });
}
/* ---- 登录 / 登出 ---- */
if (p === '/api/admin/login' && req.method === 'POST') {
if (!requireBound(res)) return;
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const out = auth.login(body.code, ip);
if (!out.ok) {
return sendJson(res, { error: out.error, fails: out.fails ?? null }, {
status: out.error === 'locked' ? 429 : 401,
headers: out.retryAfter ? { 'retry-after': String(out.retryAfter) } : {},
});
}
console.log(`[admin] 登录成功(${out.via},来自 ${ip})`);
return sendJson(res, { ok: true, via: out.via }, { headers: { 'set-cookie': SESSION_COOKIE(out.sessionId) } });
}
if (p === '/api/admin/logout' && req.method === 'POST') {
auth.destroySession(parseCookies(req.headers.cookie)[COOKIE_NAME]);
return sendJson(res, { ok: true }, { headers: { 'set-cookie': CLEAR_COOKIE() } });
}
/* ---- 以下全部需要「已绑定 + 有效会话」 ---- */
if (!requireBound(res)) return;
if (!requireSession(req, res)) return;
if (p === '/api/admin/overview' && req.method === 'GET') {
return sendJson(res, overview());
}
if (p === '/api/admin/maintenance' && req.method === 'POST') {
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const from = Date.parse(body.from);
const to = Date.parse(body.to);
if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) {
return sendJson(res, { error: 'invalid-window', hint: '开始/结束时间无效,或结束早于开始' }, { status: 400 });
}
const entry = {
from: new Date(from).toISOString(),
to: new Date(to).toISOString(),
...(body.note ? { note: String(body.note).slice(0, 200) } : {}),
...(body.id ? { id: String(body.id).slice(0, 64) } : {}),
};
savePrivateConfig(localConfigPath, (local) => {
const list = Array.isArray(local.settings?.maintenance) ? local.settings.maintenance : [];
// 只保留最近 20 条,避免配置文件被历史窗口撑大
local.settings = { ...(local.settings ?? {}), maintenance: [...list, entry].slice(-20) };
return local;
});
reloadConfig();
scheduleWrite(runtime);
return sendJson(res, { ok: true, entry });
}
if (p === '/api/admin/maintenance' && req.method === 'DELETE') {
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const target = Number(body.index);
if (!Number.isInteger(target) || target < 0) return sendJson(res, { error: 'invalid-index' }, { status: 400 });
// 越界要显式拒绝:splice 对越界下标是静默 no-op,会让「删了但没删掉」看起来像成功
const current = (() => {
try { return JSON.parse(fs.readFileSync(localConfigPath, 'utf8')).settings?.maintenance; } catch { return null; }
})();
const size = Array.isArray(current) ? current.length : 0;
if (target >= size) return sendJson(res, { error: 'invalid-index', hint: `当前共 ${size} 个维护窗口` }, { status: 400 });
savePrivateConfig(localConfigPath, (local) => {
const list = Array.isArray(local.settings?.maintenance) ? [...local.settings.maintenance] : [];
list.splice(target, 1);
local.settings = { ...(local.settings ?? {}), maintenance: list };
return local;
});
reloadConfig();
scheduleWrite(runtime);
return sendJson(res, { ok: true });
}
if (p === '/api/admin/probe-scope' && req.method === 'POST') {
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const scope = String(body.probeScope ?? '');
if (!['all', 'config', 'uptimerobot'].includes(scope)) return sendJson(res, { error: 'invalid-scope' }, { status: 400 });
savePrivateConfig(localConfigPath, (local) => {
local.settings = { ...(local.settings ?? {}), probeScope: scope };
return local;
});
reloadConfig();
scheduleWrite(runtime);
console.log(`[admin] 探测范围改为 ${scope}(来自 ${ip})`);
return sendJson(res, { ok: true, probeScope: scope });
}
if (p === '/api/admin/data-filter' && req.method === 'POST') {
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const filter = String(body.dataFilter ?? '');
if (!['both', 'config', 'uptimerobot'].includes(filter)) return sendJson(res, { error: 'invalid-filter' }, { status: 400 });
savePrivateConfig(localConfigPath, (local) => {
local.settings = { ...(local.settings ?? {}), dataFilter: filter };
return local;
});
reloadConfig();
scheduleWrite(runtime);
console.log(`[admin] 展示口径改为 ${filter}(来自 ${ip})`);
return sendJson(res, { ok: true, dataFilter: filter });
}
if (p === '/api/admin/probe-now' && req.method === 'POST') {
await Promise.race([forceRound(runtime), new Promise((r) => setTimeout(r, forcedMaxWaitMs))]);
if (res.writableEnded || res.destroyed) return;
return sendJson(res, { ok: true, stats: snapshot(runtime).stats });
}
if (p === '/api/admin/notify-test' && req.method === 'POST') {
const cfg = notifyConfig(runtime.config);
if (!cfg.enabled) return sendJson(res, { error: 'notify-disabled', hint: '未启用任何通知渠道' }, { status: 400 });
const results = await sendTestNotificationsLazy(cfg);
return sendJson(res, { ok: results.every((r) => r.ok), results });
}
if (p === '/api/admin/2fa/reset' && req.method === 'POST') {
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const out = auth.reset(body.key);
if (!out.ok) return sendJson(res, { error: out.error }, { status: 401 });
console.log(`[admin] 2FA 已重置,需重新绑定(来自 ${ip})`);
return sendJson(res, { ok: true }, { headers: { 'set-cookie': CLEAR_COOKIE() } });
}
return sendJson(res, { error: 'not-found' }, { status: 404 });
};
// 通知自检只在调用时才 import,避免主进程启动就拉起 SMTP 代码路径
async function sendTestNotificationsLazy(cfg) {
const { sendTestNotifications } = await import('./notify.js');
return sendTestNotifications(cfg, { logger: console });
}
}
+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;
}
+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');
}