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:
+38
-1
@@ -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') {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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