+160
-7
@@ -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 配置(设置页) ----
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user