export type ApiOk = { ok: true; data: T } export type ApiErr = { ok: false; error: string } export type ApiResponse = ApiOk | ApiErr const DEMO = (import.meta as any)?.env?.VITE_DEMO === '1' || (typeof window !== 'undefined' && window.location.hostname.endsWith('.hf.space')) type DemoUser = { id: string; email: string } type DemoMfa = { totpEnabled: boolean; backupBatchId: string | null } type DemoSession = { id: string; kind: 'full' | 'mfa_pending'; createdAt: string; expiresAt: string } type DemoAuditLog = { id: string action: string result: 'success' | 'failure' ip: string | null user_agent: string | null meta: Record created_at: string } type DemoRecoveryRequest = { id: string; status: 'pending' | 'completed'; createdAt: string } type DemoState = { user: DemoUser | null mfa: DemoMfa sessions: DemoSession[] currentSessionId: string | null backup: { batchId: string | null; codes: string[]; used: string[] } enroll: { secret: string | null } recovery: { requests: DemoRecoveryRequest[] } audit: { logs: DemoAuditLog[] } } const DEMO_STORAGE_KEY = 'totp_2fa_demo_state_v1' function isoNow() { return new Date().toISOString() } function randId(prefix: string) { if (typeof crypto !== 'undefined' && 'getRandomValues' in crypto) { const a = new Uint32Array(3) crypto.getRandomValues(a) return `${prefix}_${Array.from(a) .map((n) => n.toString(16)) .join('')}` } return `${prefix}_${Math.random().toString(16).slice(2)}${Math.random().toString(16).slice(2)}` } function demoDefaultState(): DemoState { return { user: null, mfa: { totpEnabled: false, backupBatchId: null }, sessions: [], currentSessionId: null, backup: { batchId: null, codes: [], used: [] }, enroll: { secret: null }, recovery: { requests: [] }, audit: { logs: [] }, } } function demoLoad(): DemoState { try { const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(DEMO_STORAGE_KEY) : null if (!raw) return demoDefaultState() const parsed = JSON.parse(raw) as DemoState if (!parsed || typeof parsed !== 'object') return demoDefaultState() return { ...demoDefaultState(), ...parsed, mfa: { ...demoDefaultState().mfa, ...(parsed.mfa ?? {}) }, backup: { ...demoDefaultState().backup, ...(parsed.backup ?? {}) }, enroll: { ...demoDefaultState().enroll, ...(parsed.enroll ?? {}) }, recovery: { ...demoDefaultState().recovery, ...(parsed.recovery ?? {}) }, audit: { ...demoDefaultState().audit, ...(parsed.audit ?? {}) }, } } catch { return demoDefaultState() } } function demoSave(s: DemoState) { if (typeof localStorage === 'undefined') return localStorage.setItem(DEMO_STORAGE_KEY, JSON.stringify(s)) } function demoAudit(s: DemoState, action: string, result: DemoAuditLog['result'], meta?: Record) { s.audit.logs.unshift({ id: randId('aud'), action, result, ip: null, user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : null, meta: meta ?? {}, created_at: isoNow(), }) } function demoEnsureDemoUser(s: DemoState): DemoUser { if (s.user) return s.user s.user = { id: 'u_demo', email: 'demo@example.com' } return s.user } function demoMakeQrDataUrl(label: string) { const svg = `${label}` return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` } function demoGenBackupCode() { const alpha = 'ABCDEFGHJKLMNPQRSTUVWXYZ' const num = '23456789' const partA = Array.from({ length: 5 }, () => alpha[Math.floor(Math.random() * alpha.length)]).join('') const partB = Array.from({ length: 5 }, () => num[Math.floor(Math.random() * num.length)]).join('') return `${partA}-${partB}` } async function demoHandle(path: string, init?: RequestInit & { json?: unknown }): Promise> { const url = new URL(path, 'https://demo.local') const method = (init?.method ?? 'GET').toUpperCase() const body = init?.json const s = demoLoad() const ok = (data: X): ApiResponse => ({ ok: true, data }) const err = (error: string): ApiResponse => ({ ok: false, error }) const current = s.currentSessionId ? s.sessions.find((x) => x.id === s.currentSessionId) ?? null : null const authed = current?.kind === 'full' const requireAuthed = () => { if (!authed) return err('请先登录') if (!s.user) return err('账号不存在') return null } if (method === 'POST' && url.pathname === '/api/auth/demo') { const user = demoEnsureDemoUser(s) const sid = randId('sess') s.sessions.unshift({ id: sid, kind: 'full', createdAt: isoNow(), expiresAt: new Date(Date.now() + 7 * 86400_000).toISOString(), }) s.currentSessionId = sid demoAudit(s, 'auth.demo', 'success') demoSave(s) return ok({ user }) } if (method === 'POST' && url.pathname === '/api/auth/demo/reset') { const next = demoDefaultState() const user = demoEnsureDemoUser(next) const sid = randId('sess') next.sessions.unshift({ id: sid, kind: 'full', createdAt: isoNow(), expiresAt: new Date(Date.now() + 7 * 86400_000).toISOString(), }) next.currentSessionId = sid demoAudit(next, 'auth.demo.reset', 'success') demoSave(next) return ok({ user }) } if (method === 'POST' && url.pathname === '/api/auth/register') { const p = body as any const email = String(p?.email ?? '').trim() const password = String(p?.password ?? '') if (!email || !password) return err('请填写邮箱与密码') s.user = { id: randId('u'), email } s.mfa = { totpEnabled: false, backupBatchId: null } s.backup = { batchId: null, codes: [], used: [] } s.enroll = { secret: null } const sid = randId('sess') s.sessions.unshift({ id: sid, kind: 'full', createdAt: isoNow(), expiresAt: new Date(Date.now() + 7 * 86400_000).toISOString(), }) s.currentSessionId = sid demoAudit(s, 'auth.register', 'success', { email }) demoSave(s) return ok({ user: s.user, mfaRequired: false }) } if (method === 'POST' && url.pathname === '/api/auth/login') { const p = body as any const email = String(p?.email ?? '').trim() const password = String(p?.password ?? '') if (!email || !password) return err('请填写邮箱与密码') if (!s.user || s.user.email !== email) s.user = { id: randId('u'), email } const needsMfa = s.mfa.totpEnabled || (s.backup.batchId && s.backup.codes.length > 0) const sid = randId('sess') s.sessions.unshift({ id: sid, kind: needsMfa ? 'mfa_pending' : 'full', createdAt: isoNow(), expiresAt: new Date(Date.now() + 7 * 86400_000).toISOString(), }) s.currentSessionId = sid demoAudit(s, 'auth.login', 'success', { mfaRequired: needsMfa }) demoSave(s) return ok({ user: s.user, mfaRequired: needsMfa }) } if (method === 'POST' && url.pathname === '/api/auth/mfa/totp') { if (!current || current.kind !== 'mfa_pending') return err('当前没有待验证的登录') const p = body as any const code = String(p?.code ?? '').trim() if (!/^\d{6}$/.test(code)) return err('请输入 6 位验证码') current.kind = 'full' demoAudit(s, 'auth.mfa.totp', 'success') demoSave(s) return ok(undefined as T) } if (method === 'POST' && url.pathname === '/api/auth/mfa/backup') { if (!current || current.kind !== 'mfa_pending') return err('当前没有待验证的登录') const p = body as any const code = String(p?.code ?? '').trim().toUpperCase() if (!code) return err('请输入备份码') if (!s.backup.codes.includes(code) || s.backup.used.includes(code)) return err('备份码无效或已使用') s.backup.used.push(code) current.kind = 'full' demoAudit(s, 'auth.mfa.backup', 'success') demoSave(s) return ok(undefined as T) } if (method === 'GET' && url.pathname === '/api/auth/me') { if (!authed || !s.user) return err('未登录') return ok({ user: s.user, mfa: { totpEnabled: s.mfa.totpEnabled, backupBatchId: s.backup.batchId } }) } if (method === 'POST' && url.pathname === '/api/auth/logout') { s.currentSessionId = null demoAudit(s, 'auth.logout', 'success') demoSave(s) return ok(undefined as T) } if (method === 'GET' && url.pathname === '/api/auth/sessions') { const r = requireAuthed() if (r) return r return ok({ currentSessionId: s.currentSessionId, sessions: s.sessions }) } if (method === 'POST' && url.pathname === '/api/auth/sessions/revoke') { const r = requireAuthed() if (r) return r const p = body as any if (p?.allOther === true) { s.sessions = s.sessions.filter((x) => x.id === s.currentSessionId) demoAudit(s, 'sessions.revoke.all_other', 'success') demoSave(s) return ok(undefined as T) } const sessionId = String(p?.sessionId ?? '').trim() if (!sessionId) return err('缺少 sessionId') s.sessions = s.sessions.filter((x) => x.id !== sessionId) if (s.currentSessionId === sessionId) s.currentSessionId = null demoAudit(s, 'sessions.revoke.one', 'success', { sessionId }) demoSave(s) return ok(undefined as T) } if (method === 'GET' && url.pathname === '/api/audit') { const r = requireAuthed() if (r) return r const limit = Number(url.searchParams.get('limit') ?? '50') const logs = s.audit.logs.slice(0, Number.isFinite(limit) ? Math.max(1, Math.min(limit, 200)) : 50) return ok({ logs }) } if (method === 'POST' && url.pathname === '/api/backup-codes/status') { return err('方法不支持') } if (method === 'GET' && url.pathname === '/api/backup-codes/status') { const r = requireAuthed() if (r) return r const total = s.backup.codes.length const used = s.backup.used.length const remaining = Math.max(0, total - used) return ok({ total, used, remaining }) } if (method === 'POST' && url.pathname === '/api/backup-codes/generate') { const r = requireAuthed() if (r) return r const codes = Array.from({ length: 10 }, () => demoGenBackupCode()) const batchId = randId('batch') s.backup = { batchId, codes, used: [] } s.mfa.backupBatchId = batchId demoAudit(s, 'backup.generate', 'success', { batchId }) demoSave(s) return ok({ codes, batchId }) } if (method === 'POST' && url.pathname === '/api/mfa/enroll/start') { const r = requireAuthed() if (r) return r const user = demoEnsureDemoUser(s) const secret = randId('secret').replaceAll('_', '').slice(0, 16).toUpperCase() s.enroll.secret = secret demoAudit(s, 'mfa.enroll.start', 'success') demoSave(s) const otpauthUrl = `otpauth://totp/${encodeURIComponent(`演示:${user.email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent('Demo')}` return ok({ otpauthUrl, qrDataUrl: demoMakeQrDataUrl('DEMO TOTP'), secretMasked: `${secret.slice(0, 2)}****${secret.slice(-2)}`, secret, }) } if (method === 'POST' && url.pathname === '/api/mfa/enroll/confirm') { const r = requireAuthed() if (r) return r const p = body as any const code = String(p?.code ?? '').trim() if (!/^\d{6}$/.test(code)) return err('请输入 6 位验证码') if (!s.enroll.secret) return err('请先开始绑定') s.mfa.totpEnabled = true s.enroll.secret = null demoAudit(s, 'mfa.enroll.confirm', 'success') demoSave(s) return ok(undefined as T) } if (method === 'POST' && url.pathname === '/api/mfa/disable') { const r = requireAuthed() if (r) return r const p = body as any const totp = String(p?.totp ?? '').trim() const backupCode = String(p?.backupCode ?? '').trim() if (!totp && !backupCode) return err('请输入 TOTP 或备份码') s.mfa.totpEnabled = false s.backup = { batchId: null, codes: [], used: [] } s.mfa.backupBatchId = null demoAudit(s, 'mfa.disable', 'success') demoSave(s) return ok(undefined as T) } if (method === 'POST' && url.pathname === '/api/recovery/start') { const r = requireAuthed() if (r) return r const p = body as any const method2 = String(p?.method ?? '') if (method2 === 'password') { const password = String(p?.password ?? '') if (!password) return err('请输入密码') } else if (method2 === 'backup_code') { const code = String(p?.backupCode ?? '').trim().toUpperCase() if (!code) return err('请输入备份码') if (!s.backup.codes.includes(code) || s.backup.used.includes(code)) return err('备份码无效或已使用') s.backup.used.push(code) } else { return err('不支持的恢复方式') } const requestId = randId('rec') s.recovery.requests.unshift({ id: requestId, status: 'pending', createdAt: isoNow() }) demoAudit(s, 'recovery.start', 'success', { method: method2 }) demoSave(s) return ok({ requestId, status: 'pending' }) } if (method === 'POST' && url.pathname === '/api/recovery/complete') { const r = requireAuthed() if (r) return r const p = body as any const requestId = String(p?.requestId ?? '').trim() if (!requestId) return err('缺少 requestId') const req = s.recovery.requests.find((x) => x.id === requestId) if (!req || req.status !== 'pending') return err('恢复请求不存在或已完成') req.status = 'completed' s.mfa.totpEnabled = false s.backup = { batchId: null, codes: [], used: [] } s.mfa.backupBatchId = null demoAudit(s, 'recovery.complete', 'success') demoSave(s) return ok(undefined as T) } return err('演示模式:接口未实现') } export async function apiRequest( path: string, init?: RequestInit & { json?: unknown }, ): Promise> { if (DEMO && path.startsWith('/api/')) return demoHandle(path, init) const headers = new Headers(init?.headers) if (init?.json !== undefined) headers.set('Content-Type', 'application/json') const res = await fetch(path, { ...init, credentials: 'include', headers, body: init?.json !== undefined ? JSON.stringify(init.json) : init?.body, }) const text = await res.text() try { return JSON.parse(text) as ApiResponse } catch { return { ok: false, error: '服务返回了非 JSON 响应' } } } export async function apiMust( path: string, init?: RequestInit & { json?: unknown }, ): Promise { const r = await apiRequest(path, init) if (r.ok === false) throw new Error(r.error) return r.data }