/** * 管理端鉴权: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; }