安全页重构:免密登录与活跃会话;按钮规范统一
CI / test (push) Successful in 53s

This commit is contained in:
2026-07-30 12:23:16 +08:00
parent d0fbecbd43
commit d675b950ee
43 changed files with 1944 additions and 475 deletions
+160 -7
View File
@@ -1,3 +1,10 @@
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/browser'
import { mockOn, mocked, request } from './request'
import type {
CredentialsInfo,
@@ -5,6 +12,9 @@ import type {
LoginRequest,
LoginResponse,
OAuthSettings,
PasskeyCeremonyOptions,
PasskeyInfo,
SessionItem,
SessionRefresh,
TotpSetup,
TotpStatus,
@@ -32,7 +42,36 @@ export function logout(): Promise<void> {
/** 撤销全部会话:旧 token 全部失效,响应带操作者的新会话 */
export function revokeSessions(): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/revoke-sessions', { method: 'POST' })
return request('/auth/revoke-sessions', { method: 'POST', refreshesSession: true })
}
/** mock 会话样例:相对当前时间偏移,保证「最近活跃」展示合理 */
function mockSessions(): { items: SessionItem[] } {
const ago = (min: number) => new Date(Date.now() - min * 60000).toISOString()
const later = new Date(Date.now() + 23 * 3600000).toISOString()
const mk = (id: number, method: string, clientIp: string, userAgent: string, seen: number, created: number, current = false): SessionItem => ({
id, method, clientIp, userAgent,
createdAt: ago(created), lastSeenAt: ago(seen), expiresAt: later, current,
})
return {
items: [
mk(1, 'password', '198.51.100.7', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0 Safari/537.36', 0, 180, true),
mk(2, 'passkey', '203.0.113.24', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Version/17.5 Mobile/15E148 Safari/604.1', 185, 205),
mk(3, 'wallet', '192.0.2.88', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0 Safari/537.36 Edg/126.0', 780, 800),
],
}
}
/** 活跃会话列表:未撤销、未过期、版本为当前,最近活跃在前 */
export function listSessions(): Promise<{ items: SessionItem[] }> {
if (mockOn) return mocked(mockSessions(), 300)
return request('/auth/sessions')
}
/** 定点撤销一个其他会话;当前会话不可撤销(请走退出登录) */
export function revokeSession(id: number): Promise<void> {
if (mockOn) return mocked(undefined, 250)
return request(`/auth/sessions/${id}`, { method: 'DELETE' })
}
// ---- 登录凭据 ----
@@ -45,13 +84,17 @@ export function getCredentials(): Promise<CredentialsInfo> {
/** 修改用户名 / 密码;成功后旧 token 全部失效,响应带操作者的新会话 */
export function updateCredentials(body: UpdateCredentialsRequest): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 400)
return request('/auth/credentials', { method: 'PUT', body })
return request('/auth/credentials', { method: 'PUT', body, refreshesSession: true })
}
/** 保存密码登录禁用开关;成功后旧 token 全部失效,响应带新会话 */
export function updatePasswordLogin(disabled: boolean): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/password-login', { method: 'PUT', body: { disabled } })
return request('/auth/password-login', {
method: 'PUT',
body: { disabled },
refreshesSession: true,
})
}
// ---- 两步验证(TOTP) ----
@@ -74,22 +117,29 @@ export function setupTotp(): Promise<TotpSetup> {
/** 激活两步验证;成功后旧 token 全部失效,响应带新会话 */
export function activateTotp(code: string): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/totp/activate', { method: 'POST', body: { code } })
return request('/auth/totp/activate', {
method: 'POST',
body: { code },
refreshesSession: true,
})
}
/** 停用两步验证;密码或当前验证码任一确认,响应带新会话 */
export function disableTotp(body: { password?: string; code?: string }): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/totp/disable', { method: 'POST', body })
return request('/auth/totp/disable', { method: 'POST', body, refreshesSession: true })
}
// ---- 外部身份(OAuth) ----
/** 可登录的 provider 列表(登录页公开接口;已禁用的不返回);
* passwordLoginDisabled 为 true 且有可用 provider 时,登录页隐藏密码表单 */
* passwordLoginDisabled 为 true 且有可用 provider 时,登录页隐藏密码表单;
* passkeyLogin / walletLogin 为 true 表示存在对应凭据,登录页显示入口 */
export function getOauthProviders(): Promise<{
providers: OauthProviderInfo[]
passwordLoginDisabled: boolean
passkeyLogin: boolean
walletLogin: boolean
}> {
if (mockOn)
return mocked({
@@ -98,6 +148,8 @@ export function getOauthProviders(): Promise<{
{ provider: 'oidc', displayName: 'OIDC SSO' },
],
passwordLoginDisabled: false,
passkeyLogin: true,
walletLogin: true,
})
return request('/auth/oauth/providers')
}
@@ -124,7 +176,108 @@ export function listIdentities(): Promise<{ items: UserIdentityInfo[] }> {
/** 解绑外部身份;成功后旧 token 全部失效,响应带新会话 */
export function unbindIdentity(id: number): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request(`/auth/identities/${id}`, { method: 'DELETE' })
return request(`/auth/identities/${id}`, { method: 'DELETE', refreshesSession: true })
}
// ---- 通行密钥(Passkey) ----
const mockPasskeyRegisterOptions: PasskeyCeremonyOptions<PublicKeyCredentialCreationOptionsJSON> = {
sessionId: 'mock-session',
options: {
publicKey: {
challenge: 'bW9jaw',
rp: { id: 'localhost', name: 'OCI Portal' },
user: { id: 'AQ', name: 'admin', displayName: 'admin' },
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
},
},
}
/** 发起通行密钥注册;sessionId 需原样带回 finish */
export function beginPasskeyRegister(): Promise<
PasskeyCeremonyOptions<PublicKeyCredentialCreationOptionsJSON>
> {
if (mockOn) return mocked(mockPasskeyRegisterOptions, 300)
return request('/auth/passkey/register/begin', { method: 'POST' })
}
/** 完成通行密钥注册;成功后旧 token 全部失效,响应带新会话 */
export function finishPasskeyRegister(
sessionId: string,
name: string,
credential: RegistrationResponseJSON,
): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/passkey/register/finish', {
method: 'POST',
body: { sessionId, name, credential },
refreshesSession: true,
})
}
export function listPasskeys(): Promise<{ items: PasskeyInfo[] }> {
if (mockOn)
return mocked({
items: [
{ id: 1, name: 'MacBook Touch ID', createdAt: '2026-07-20T10:00:00+08:00', lastUsedAt: null },
],
})
return request('/auth/passkeys')
}
/** 删除通行密钥;成功后旧 token 全部失效,响应带新会话 */
export function removePasskey(id: number): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request(`/auth/passkeys/${id}`, { method: 'DELETE', refreshesSession: true })
}
/** 发起通行密钥登录(公开接口) */
export function beginPasskeyLogin(): Promise<
PasskeyCeremonyOptions<PublicKeyCredentialRequestOptionsJSON>
> {
if (mockOn)
return mocked({
sessionId: 'mock-session',
options: { publicKey: { challenge: 'bW9jaw', rpId: 'localhost' } },
})
return request('/auth/passkey/login/begin', { method: 'POST' })
}
/** 完成通行密钥登录;UV 通过后豁免 TOTP */
export function finishPasskeyLogin(
sessionId: string,
credential: AuthenticationResponseJSON,
): Promise<LoginResponse> {
if (mockOn) return mocked({ token: 'mock-token', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/passkey/login/finish', {
method: 'POST',
body: { sessionId, credential },
})
}
// ---- Web3 钱包(SIWE) ----
/** 发起钱包签名挑战;mode=bind 要求已登录,message 需原样 personal_sign */
export function getWalletChallenge(
address: string,
mode: 'login' | 'bind',
): Promise<{ nonce: string; message: string }> {
if (mockOn)
return mocked({
nonce: 'mock-nonce',
message: `localhost wants you to sign in with your Ethereum account:\n${address}\n\n登录 OCI Portal 面板\n\nNonce: mock-nonce`,
})
return request('/auth/wallet/challenge', { method: 'POST', body: { address, mode } })
}
/** 校验钱包签名:login 返回新会话;bind 返回换发的新 token(旧会话已失效) */
export function verifyWallet(nonce: string, signature: string): Promise<LoginResponse> {
if (mockOn) return mocked({ token: 'mock-token', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/wallet/verify', {
method: 'POST',
body: { nonce, signature },
refreshesSession: true,
})
}
// ---- OAuth provider 配置(设置页) ----
+77
View File
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { terminateInstance, terminatingInstances, terminatingKey } from './instances'
/** 可控完成时机的 fetch stub;fail 控制 resolve 后是成功还是 500 */
function stubTerminateFetch(fail = false) {
const calls = { count: 0 }
let release!: () => void
const gate = new Promise<void>((r) => (release = r))
vi.stubGlobal('fetch', async () => {
calls.count++
await gate
if (fail) {
return {
ok: false,
status: 500,
url: '/api/v1/x',
json: async () => ({ error: '终止失败' }),
} as unknown as Response
}
return { ok: true, status: 200, text: async () => '' } as unknown as Response
})
return { calls, release }
}
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
terminatingInstances.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('共享终止锁', () => {
it('请求飞行中 key 在集合内,完成后移除', async () => {
const { release } = stubTerminateFetch()
const p = terminateInstance(3, 'ocid1.instance..x', false)
expect(terminatingInstances.has(terminatingKey(3, 'ocid1.instance..x'))).toBe(true)
release()
await p
expect(terminatingInstances.size).toBe(0)
})
it('请求失败同样释放锁,不留脏状态', async () => {
const { release } = stubTerminateFetch(true)
const p = terminateInstance(3, 'ocid1.instance..x', true)
expect(terminatingInstances.size).toBe(1)
release()
await expect(p).rejects.toMatchObject({ message: '终止失败' })
expect(terminatingInstances.size).toBe(0)
})
it('同一物理实例跨配置共享 key,后到调用被拒', async () => {
const { calls, release } = stubTerminateFetch()
const instanceId = 'ocid1.instance.oc1..same'
const first = terminateInstance(1, instanceId, false)
await expect(terminateInstance(2, instanceId, true)).rejects.toMatchObject({ status: 409 })
expect(terminatingKey(1, instanceId)).toBe(terminatingKey(2, instanceId))
expect(calls.count).toBe(1)
release()
await first
})
it('不同实例互不影响', async () => {
const { release } = stubTerminateFetch()
const pa = terminateInstance(1, 'a', false)
const pb = terminateInstance(2, 'b', false)
expect(terminatingInstances.has(terminatingKey(1, 'a'))).toBe(true)
expect(terminatingInstances.has(terminatingKey(2, 'b'))).toBe(true)
expect(terminatingKey(1, 'a')).not.toBe(terminatingKey(2, 'b'))
release()
await Promise.all([pa, pb])
expect(terminatingInstances.size).toBe(0)
})
})
+33 -3
View File
@@ -8,7 +8,9 @@ import {
mockVolAttachments,
mockVolumes,
} from './mock'
import { mockOn, mocked, request } from './request'
import { reactive } from 'vue'
import { ApiError, mockOn, mocked, request } from './request'
import type {
AttachVnicRequest,
BootVolumeAttachment,
@@ -118,13 +120,41 @@ export function updateInstance(
})
}
export function terminateInstance(
/** 终止请求飞行中的实例(instance OCID):模块级响应式集合,列表页、
* 详情页与组件重挂载共用一把锁,防「关闭重开对话框后用相反的
* preserveBootVolume 并发提交」;跨浏览器页签由服务端同键 guard 兜底 */
export const terminatingInstances = reactive(new Set<string>())
/** Instance OCID 唯一标识物理实例;cfgId 不得拆成两把终止锁 */
export function terminatingKey(_cfgId: number, instanceId: string): string {
return instanceId
}
export async function terminateInstance(
cfgId: number,
instanceId: string,
preserveBootVolume: boolean,
region?: string,
): Promise<void> {
const key = terminatingKey(cfgId, instanceId)
if (terminatingInstances.has(key)) {
throw new ApiError(409, '该实例的终止请求正在处理中')
}
terminatingInstances.add(key)
try {
if (mockOn) return await mocked(undefined, 600)
return await requestTerminate(cfgId, instanceId, preserveBootVolume, region)
} finally {
terminatingInstances.delete(key)
}
}
function requestTerminate(
cfgId: number,
instanceId: string,
preserveBootVolume: boolean,
region?: string,
): Promise<void> {
if (mockOn) return mocked(undefined, 600)
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}`, {
method: 'DELETE',
query: { preserveBootVolume, region },
+164
View File
@@ -21,6 +21,27 @@ function stub401(rotateTo?: string) {
})
}
function stubRefreshThen401() {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))
vi.stubGlobal('fetch', async (input: RequestInfo | URL) => {
if (String(input).endsWith('/refresh')) {
await gate
return {
ok: true,
status: 200,
text: async () => '{"token":"tokB","expiresAt":"2099-01-01T00:00:00Z"}',
} as unknown as Response
}
return {
ok: false,
status: 401,
json: async () => ({ error: '令牌已失效' }),
} as unknown as Response
})
return release
}
let assignSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
@@ -34,6 +55,89 @@ afterEach(() => {
vi.restoreAllMocks()
})
/** 可控完成时机的 fetch stub,记录调用次数 */
function stubSlowFetch() {
const calls = { count: 0 }
let release!: () => void
const gate = new Promise<void>((r) => (release = r))
vi.stubGlobal('fetch', async () => {
calls.count++
await gate
return { ok: true, status: 200, text: async () => '{"ok":true}' } as unknown as Response
})
return { calls, release }
}
describe('写请求 in-flight 去重', () => {
it('并发相同写请求只发一次,共享同一结果', async () => {
const { calls, release } = stubSlowFetch()
const p1 = request('/y', { method: 'POST', body: { a: 1 } })
const p2 = request('/y', { method: 'POST', body: { a: 1 } })
release()
expect(await Promise.all([p1, p2])).toEqual([{ ok: true }, { ok: true }])
expect(calls.count).toBe(1)
})
it('body 不同不去重;完成后同 key 可再发', async () => {
const { calls, release } = stubSlowFetch()
const p1 = request('/y', { method: 'POST', body: { a: 1 } })
const p2 = request('/y', { method: 'POST', body: { a: 2 } })
release()
await Promise.all([p1, p2])
expect(calls.count).toBe(2)
await request('/y', { method: 'POST', body: { a: 1 } })
expect(calls.count).toBe(3)
})
it('GET 不去重', async () => {
const { calls, release } = stubSlowFetch()
const p1 = request('/y')
const p2 = request('/y')
release()
await Promise.all([p1, p2])
expect(calls.count).toBe(2)
})
})
/** 构造 429 响应;body 由调用方给定(带不带 code 决定分流) */
function stub429(body: Record<string, unknown>) {
vi.stubGlobal('fetch', async () => {
return {
ok: false,
status: 429,
url: '/api/v1/x',
json: async () => body,
} as unknown as Response
})
}
describe('429 分流', () => {
it('全局 IP 限流(code=RateLimited):整页跳转 /blocked', async () => {
setPageURL('http://localhost/instances')
stub429({ error: 'rate limit exceeded', code: 'RateLimited' })
await expect(request('/x')).rejects.toMatchObject({ status: 429 })
expect(assignSpy).toHaveBeenCalledWith('/blocked')
})
it('登录守卫锁定(无 code):不跳转,留在表单内提示', async () => {
setPageURL('http://localhost/login')
stub429({ error: '尝试过于频繁,请稍后再试' })
await expect(request('/auth/wallet/verify', { method: 'POST', body: {} })).rejects.toMatchObject(
{ status: 429, message: '尝试过于频繁,请稍后再试' },
)
expect(assignSpy).not.toHaveBeenCalled()
})
it('设置页绑定遇全局限流:同样进入 /blocked(不按 URL 判断)', async () => {
setPageURL('http://localhost/settings')
stub429({ error: 'rate limit exceeded', code: 'RateLimited' })
await expect(
request('/auth/wallet/challenge', { method: 'POST', body: {} }),
).rejects.toMatchObject({ status: 429 })
expect(assignSpy).toHaveBeenCalledWith('/blocked')
})
})
describe('401 令牌快照', () => {
it('当前 token 收到 401:登出并带 redirect 跳登录', async () => {
setPageURL('http://localhost/instances?tab=list')
@@ -55,6 +159,66 @@ describe('401 令牌快照', () => {
expect(assignSpy).not.toHaveBeenCalled()
})
it('401 先到时等待并发换新响应落入 Store,不得提前登出', async () => {
setPageURL('http://localhost/settings')
useAuthStore().setSession('tokA', '')
const releaseRefresh = stubRefreshThen401()
const refresh = request<{ token: string; expiresAt: string }>('/refresh', {
method: 'POST',
refreshesSession: true,
})
const stale = request('/stale', { method: 'POST' }).catch((error: unknown) => error)
await Promise.resolve()
expect(useAuthStore().token).toBe('tokA')
expect(assignSpy).not.toHaveBeenCalled()
releaseRefresh()
const next = await refresh
expect(next.token).toBe('tokB')
expect(await stale).toBeInstanceOf(ApiError)
expect(useAuthStore().token).toBe('tokB')
expect(assignSpy).not.toHaveBeenCalled()
})
it('换发 Promise 完成时已先写 Store,不给迟到 401 留微任务窗口', async () => {
setPageURL('http://localhost/settings')
useAuthStore().setSession('tokA', '')
vi.stubGlobal('fetch', async () => {
return {
ok: true,
status: 200,
text: async () => '{"token":"tokB","expiresAt":"2099-01-01T00:00:00Z"}',
} as unknown as Response
})
await request('/refresh', { method: 'POST', refreshesSession: true })
expect(useAuthStore().token).toBe('tokB')
})
it('公开登录没有旧 token 时仍由页面接管新会话', async () => {
vi.stubGlobal('fetch', async () => {
return {
ok: true,
status: 200,
text: async () => '{"token":"tokB","expiresAt":"2099-01-01T00:00:00Z"}',
} as unknown as Response
})
await request('/login', { method: 'POST', refreshesSession: true })
expect(useAuthStore().token).toBe('')
})
it('并发换新请求都返回 401 时不得互相等待', async () => {
setPageURL('http://localhost/settings')
useAuthStore().setSession('tokA', '')
stub401()
const opts = { method: 'POST', refreshesSession: true } as const
const results = await Promise.allSettled([
request('/refresh-a', opts),
request('/refresh-b', opts),
])
expect(results.map((result) => result.status)).toEqual(['rejected', 'rejected'])
expect(useAuthStore().token).toBe('')
expect(assignSpy).toHaveBeenCalledTimes(1)
})
it('登录页密码错误的 401:不跳转,错误留在表单内提示', async () => {
setPageURL('http://localhost/login')
stub401()
+126 -24
View File
@@ -20,8 +20,27 @@ interface RequestOptions {
query?: Record<string, string | number | boolean | undefined>
/** 附加请求头 */
headers?: Record<string, string>
/** 成功响应会换发当前会话;并发 401 须等待其落入 Store 后再决定是否登出 */
refreshesSession?: boolean
}
interface RefreshWaiter {
resolve: (waited: boolean) => void
}
interface RefreshState {
count: number
waiters: RefreshWaiter[]
}
interface ErrorDetails {
message: string
ociCode?: string
rateLimited: boolean
}
const sessionRefreshes = new Map<string, RefreshState>()
function buildUrl(path: string, query?: RequestOptions['query']): string {
if (!query) return BASE + path
const qs = new URLSearchParams()
@@ -32,40 +51,46 @@ function buildUrl(path: string, query?: RequestOptions['query']): string {
return s ? `${BASE}${path}?${s}` : BASE + path
}
async function parseError(resp: Response, sentToken: string): Promise<never> {
let message = `请求失败(${resp.status})`
let ociCode: string | undefined
async function parseError(
resp: Response,
sentToken: string,
): Promise<never> {
const details = await readErrorDetails(resp)
if (resp.status === 401) await handleUnauthorized(sentToken)
if (resp.status === 429 && details.rateLimited && location.pathname !== '/blocked') {
location.assign('/blocked')
}
throw new ApiError(resp.status, details.message, details.ociCode)
}
async function readErrorDetails(resp: Response): Promise<ErrorDetails> {
const fallback = { message: `请求失败(${resp.status})`, rateLimited: false }
try {
const body = (await resp.json()) as {
error?: string
hint?: string
errors?: unknown[]
ociCode?: string
code?: string
}
ociCode = body.ociCode
if (body.error) {
message = body.hint ? `${body.hint}|${body.error}` : body.error
} else {
// 批量接口(如创建实例)全部失败时返回 errors 数组,逐行合并;
// 调用方以「短标题 + detail」经 useToast 展示,多行在详情块中逐行可读
const list = (body.errors ?? []).filter((e): e is string => typeof e === 'string')
if (list.length) message = list.join('\n')
}
const list = (body.errors ?? []).filter((e): e is string => typeof e === 'string')
const message = body.error
? body.hint
? `${body.hint}|${body.error}`
: body.error
: list.join('\n') || fallback.message
return { message, ociCode: body.ociCode, rateLimited: body.code === 'RateLimited' }
} catch {
/* 非 JSON 响应体,保留默认消息 */
return fallback
}
if (resp.status === 401) handleUnauthorized(sentToken)
// 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示
if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') {
location.assign('/blocked')
}
throw new ApiError(resp.status, message, ociCode)
}
/** 401 处理:仅当失败请求发送时的 token 仍是当前会话 token 才登出——换发新
* token 后(OAuth 绑定回跳等),旧 token 请求的迟到 401 不得清掉新会话;
* 登出后带 redirect 统一跳登录页,不把用户留在满屏报错的页面上。 */
function handleUnauthorized(sentToken: string) {
async function handleUnauthorized(sentToken: string) {
const waited = await waitForSessionRefresh(sentToken)
if (waited) await new Promise<void>((resolve) => setTimeout(resolve, 0))
const auth = useAuthStore()
if (sentToken !== auth.token) return
auth.logout()
@@ -75,8 +100,80 @@ function handleUnauthorized(sentToken: string) {
}
}
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
/** 写请求(非 GET)在飞行中的去重表:key → 共享 Promise */
const inflightWrites = new Map<string, Promise<unknown>>()
function beginSessionRefresh(token: string) {
const state = sessionRefreshes.get(token)
if (state) {
state.count++
return
}
sessionRefreshes.set(token, { count: 1, waiters: [] })
}
function endSessionRefresh(token: string) {
const state = sessionRefreshes.get(token)
if (!state) return
state.count--
state.waiters = state.waiters.filter((waiter) => {
if (state.count > 0) return true
waiter.resolve(true)
return false
})
if (state.count === 0) sessionRefreshes.delete(token)
}
function waitForSessionRefresh(token: string): Promise<boolean> {
const state = sessionRefreshes.get(token)
if (!state || state.count === 0) return Promise.resolve(false)
return new Promise((resolve) => state.waiters.push({ resolve }))
}
function trackSessionRefresh(token: string): () => void {
beginSessionRefresh(token)
let active = true
return () => {
if (!active) return
active = false
endSessionRefresh(token)
}
}
function applySessionRefresh(value: unknown) {
if (!value || typeof value !== 'object') return
const refresh = value as { token?: unknown; expiresAt?: unknown }
if (typeof refresh.token !== 'string' || typeof refresh.expiresAt !== 'string') return
useAuthStore().setSession(refresh.token, refresh.expiresAt)
}
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError;
* 非 GET 请求按 token+method+url+body 做 in-flight 去重——前一发未返回时复用同一 Promise,
* 防连点造成重复提交;完成(无论成败)即移除。GET 不去重(useAsync 已有后发优先语义)。
* 键含认证上下文:token 换发/切换账号后不得复用旧会话在飞行中的请求 */
export function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const method = (opts.method ?? 'GET').toUpperCase()
if (method === 'GET') return doRequest<T>(path, opts)
const body = opts.body === undefined ? '' : JSON.stringify(opts.body)
const sentToken = useAuthStore().token ?? ''
const key = `${sentToken} ${method} ${buildUrl(path, opts.query)} ${body}`
const existing = inflightWrites.get(key)
if (existing) return existing as Promise<T>
const tracksRefresh = opts.refreshesSession === true && sentToken !== ''
const finishRefresh = tracksRefresh ? trackSessionRefresh(sentToken) : () => {}
const p = doRequest<T>(path, opts, finishRefresh).finally(() => {
inflightWrites.delete(key)
finishRefresh()
})
inflightWrites.set(key, p)
return p
}
async function doRequest<T>(
path: string,
opts: RequestOptions,
finishRefresh: () => void = () => {},
): Promise<T> {
const auth = useAuthStore()
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers }
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
@@ -86,10 +183,15 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
headers,
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
})
if (!resp.ok) await parseError(resp, sentToken)
if (!resp.ok) {
if (resp.status === 401) finishRefresh()
await parseError(resp, sentToken)
}
// 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input
const text = await resp.text()
return (text ? JSON.parse(text) : undefined) as T
const result = (text ? JSON.parse(text) : undefined) as T
if (opts.refreshesSession && sentToken) applySessionRefresh(result)
return result
}
/** 原始请求:注入 JWT、错误同 request 转 ApiError,返回原始 Response(二进制内容读写用) */
+9 -3
View File
@@ -214,12 +214,18 @@ export function deleteUserApiKey(id: number, userId: string, fingerprint: string
})
}
/** 把刚创建的 key 设为本配置签名凭据(验证可用后落库,不删旧 key);私钥为创建时下发的那份回传 */
export function activateApiKey(id: number, fingerprint: string, privateKey: string): Promise<void> {
/** 把刚创建的 key 设为本配置签名凭据(验证可用后落库,不删旧 key);私钥为创建时下发的那份回传;
* userId 给定且异于当前签名用户时,一并把面板签名用户切换为该用户 */
export function activateApiKey(
id: number,
fingerprint: string,
privateKey: string,
userId?: string,
): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${id}/activate-api-key`, {
method: 'POST',
body: { fingerprint, privateKey },
body: { userId, fingerprint, privateKey },
})
}