| export type ApiOk<T> = { ok: true; data: T } |
| export type ApiErr = { ok: false; error: string } |
| export type ApiResponse<T> = ApiOk<T> | 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<string, unknown> |
| 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<string, unknown>) { |
| 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240"><rect width="100%" height="100%" fill="#fff"/><rect x="8" y="8" width="224" height="224" fill="none" stroke="#111" stroke-width="2"/><text x="120" y="120" text-anchor="middle" dominant-baseline="middle" font-family="monospace" font-size="14" fill="#111">${label}</text></svg>` |
| 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<T>(path: string, init?: RequestInit & { json?: unknown }): Promise<ApiResponse<T>> { |
| const url = new URL(path, 'https://demo.local') |
| const method = (init?.method ?? 'GET').toUpperCase() |
| const body = init?.json |
| const s = demoLoad() |
|
|
| const ok = <X,>(data: X): ApiResponse<X> => ({ ok: true, data }) |
| const err = (error: string): ApiResponse<never> => ({ 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<T>( |
| path: string, |
| init?: RequestInit & { json?: unknown }, |
| ): Promise<ApiResponse<T>> { |
| if (DEMO && path.startsWith('/api/')) return demoHandle<T>(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<T> |
| } catch { |
| return { ok: false, error: '服务返回了非 JSON 响应' } |
| } |
| } |
|
|
| export async function apiMust<T>( |
| path: string, |
| init?: RequestInit & { json?: unknown }, |
| ): Promise<T> { |
| const r = await apiRequest<T>(path, init) |
| if (r.ok === false) throw new Error(r.error) |
| return r.data |
| } |
|
|