3v324v23's picture
优化一下
cbdcc4a
Raw
History Blame Contribute Delete
15 kB
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
}