mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -46,6 +46,21 @@ describe('authApi turnstile payloads', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('binds verification and status requests to the verification session token', async () => {
|
||||
await authApi.verifyEmail('[email protected]', '123456', 'verification-session-token')
|
||||
await authApi.getVerificationStatus('[email protected]', 'verification-session-token')
|
||||
|
||||
expect(postMock).toHaveBeenNthCalledWith(1, '/api/auth/verify-email', {
|
||||
email: '[email protected]',
|
||||
code: '123456',
|
||||
verification_token: 'verification-session-token',
|
||||
})
|
||||
expect(postMock).toHaveBeenNthCalledWith(2, '/api/auth/verification-status', {
|
||||
email: '[email protected]',
|
||||
verification_token: 'verification-session-token',
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes auth token without a request body', async () => {
|
||||
postMock.mockResolvedValue({ data: { access_token: 'new-access-token' } })
|
||||
|
||||
@@ -54,4 +69,12 @@ describe('authApi turnstile payloads', () => {
|
||||
expect(postMock).toHaveBeenCalledWith('/api/auth/refresh')
|
||||
expect(setTokenMock).toHaveBeenCalledWith('new-access-token')
|
||||
})
|
||||
|
||||
it('publishes login session availability without changing the token payload', async () => {
|
||||
postMock.mockResolvedValue({ data: { access_token: 'login-access-token' } })
|
||||
|
||||
await authApi.login({ email: '[email protected]', password: 'secret123' })
|
||||
|
||||
expect(setTokenMock).toHaveBeenCalledWith('login-access-token', true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AxiosAdapter, AxiosInstance, InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
import apiClient, { AUTH_STATE_CHANGE_EVENT } from '@/api/client'
|
||||
import apiClient, {
|
||||
AUTH_SESSION_SIGNAL_KEY,
|
||||
AUTH_STATE_CHANGE_EVENT,
|
||||
parseAuthSessionSignal,
|
||||
} from '@/api/client'
|
||||
import { cache, cachedRequest } from '@/utils/cache'
|
||||
|
||||
type TestableApiClient = typeof apiClient & {
|
||||
@@ -24,17 +28,82 @@ describe('apiClient auth state change event', () => {
|
||||
window.addEventListener(AUTH_STATE_CHANGE_EVENT, handler as EventListener)
|
||||
|
||||
apiClient.setToken('access-token')
|
||||
localStorage.setItem('access_token', 'legacy-local-token')
|
||||
sessionStorage.setItem('access_token', 'legacy-session-token')
|
||||
apiClient.clearAuth()
|
||||
|
||||
expect(localStorage.getItem('access_token')).toBeNull()
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
expect(sessionStorage.getItem('access_token')).toBeNull()
|
||||
expect(handler).toHaveBeenCalledTimes(2)
|
||||
|
||||
const event = handler.mock.calls[0][0] as CustomEvent<{ token: string | null }>
|
||||
expect(event.detail).toEqual({ token: null })
|
||||
const event = handler.mock.calls[1][0] as CustomEvent<{ authenticated: boolean }>
|
||||
expect(event.detail).toEqual({ authenticated: false })
|
||||
|
||||
window.removeEventListener(AUTH_STATE_CHANGE_EVENT, handler as EventListener)
|
||||
})
|
||||
|
||||
it('keeps access tokens in memory and only stores token-free session metadata', () => {
|
||||
apiClient.setToken('sensitive-access-token', true)
|
||||
|
||||
expect(apiClient.getToken()).toBe('sensitive-access-token')
|
||||
expect(localStorage.getItem('access_token')).toBeNull()
|
||||
expect(sessionStorage.getItem('access_token')).toBeNull()
|
||||
|
||||
const rawSignal = localStorage.getItem(AUTH_SESSION_SIGNAL_KEY)
|
||||
expect(rawSignal).not.toContain('sensitive-access-token')
|
||||
expect(parseAuthSessionSignal(rawSignal)).toMatchObject({ authenticated: true })
|
||||
})
|
||||
|
||||
it('restores a session through the refresh cookie and stores the result in memory only', async () => {
|
||||
const rawClient = apiClient as TestableApiClient
|
||||
const previousAdapter = rawClient.client.defaults.adapter
|
||||
|
||||
rawClient.client.defaults.adapter = (async (config: InternalAxiosRequestConfig) => ({
|
||||
data: { access_token: 'restored-access-token' },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
})) as AxiosAdapter
|
||||
|
||||
try {
|
||||
await expect(apiClient.restoreSession()).resolves.toBe('restored-access-token')
|
||||
expect(apiClient.getToken()).toBe('restored-access-token')
|
||||
expect(localStorage.getItem('access_token')).toBeNull()
|
||||
expect(sessionStorage.getItem('access_token')).toBeNull()
|
||||
} finally {
|
||||
rawClient.client.defaults.adapter = previousAdapter
|
||||
}
|
||||
})
|
||||
|
||||
it('does not resurrect a session when logout wins an in-flight restore', async () => {
|
||||
const rawClient = apiClient as TestableApiClient
|
||||
const previousAdapter = rawClient.client.defaults.adapter
|
||||
let resolveRefresh!: (response: Awaited<ReturnType<AxiosAdapter>>) => void
|
||||
|
||||
rawClient.client.defaults.adapter = (() => new Promise((resolve) => {
|
||||
resolveRefresh = resolve
|
||||
})) as AxiosAdapter
|
||||
|
||||
try {
|
||||
const restore = apiClient.restoreSession()
|
||||
await vi.waitFor(() => expect(resolveRefresh).toBeTypeOf('function'))
|
||||
apiClient.clearAuth()
|
||||
resolveRefresh({
|
||||
data: { access_token: 'stale-access-token' },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config: {} as InternalAxiosRequestConfig,
|
||||
})
|
||||
|
||||
await expect(restore).rejects.toThrow('Auth state changed')
|
||||
expect(apiClient.getToken()).toBeNull()
|
||||
} finally {
|
||||
rawClient.client.defaults.adapter = previousAdapter
|
||||
}
|
||||
})
|
||||
|
||||
it('clears cached API data whenever the authentication identity changes', () => {
|
||||
apiClient.setToken('first-token')
|
||||
cache.set('dashboard', { owner: 'first-user' }, 30_000)
|
||||
@@ -99,4 +168,32 @@ describe('apiClient auth state change event', () => {
|
||||
rawClient.client.defaults.adapter = previousAdapter
|
||||
}
|
||||
})
|
||||
|
||||
it('authenticates protected gateway operational requests', async () => {
|
||||
const rawClient = apiClient as TestableApiClient
|
||||
const previousAdapter = rawClient.client.defaults.adapter
|
||||
const requests: InternalAxiosRequestConfig[] = []
|
||||
|
||||
rawClient.client.defaults.adapter = (async (config: InternalAxiosRequestConfig) => {
|
||||
requests.push(config)
|
||||
return {
|
||||
data: '',
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
}
|
||||
}) as AxiosAdapter
|
||||
|
||||
try {
|
||||
apiClient.setToken('operational-access-token')
|
||||
await apiClient.get('/_gateway/metrics')
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0].headers.Authorization).toBe('Bearer operational-access-token')
|
||||
expect(requests[0].headers['X-Client-Device-Id']).toBeTruthy()
|
||||
} finally {
|
||||
rawClient.client.defaults.adapter = previousAdapter
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,8 +11,23 @@ export interface PaymentCallbackRecord {
|
||||
payload_hash: string | null
|
||||
signature_valid: boolean
|
||||
status: string
|
||||
payload: Record<string, unknown> | null
|
||||
payload: null
|
||||
has_payload: boolean
|
||||
payload_summary: {
|
||||
kind: 'null' | 'boolean' | 'number' | 'string' | 'array' | 'object'
|
||||
serialized_bytes: number
|
||||
objects: number
|
||||
arrays: number
|
||||
strings: number
|
||||
numbers: number
|
||||
booleans: number
|
||||
nulls: number
|
||||
object_fields: number
|
||||
array_items: number
|
||||
max_depth: number
|
||||
} | null
|
||||
error_message: string | null
|
||||
has_error_message: boolean
|
||||
created_at: string
|
||||
processed_at: string | null
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export interface UserExport {
|
||||
email: string
|
||||
email_verified?: boolean
|
||||
username: string
|
||||
password_hash: string
|
||||
password_hash?: string | null
|
||||
role: string
|
||||
allowed_providers?: string[] | null
|
||||
allowed_providers_mode?: 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
|
||||
@@ -169,9 +169,11 @@ export interface UserExport {
|
||||
|
||||
export interface UserApiKeyExport {
|
||||
api_key_id?: string
|
||||
// Legacy 1.3-1.5 import-only credential fields. Version 1.6 exports omit them.
|
||||
key?: string | null
|
||||
key_hash: string
|
||||
key_hash?: string | null
|
||||
key_encrypted?: string | null
|
||||
credential_state?: 'not_exported'
|
||||
name?: string | null
|
||||
is_standalone: boolean
|
||||
allowed_providers?: string[] | null
|
||||
|
||||
@@ -204,6 +204,14 @@ export const asyncTasksApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getVideoBlob(taskId: string): Promise<Blob> {
|
||||
const response = await apiClient.get<Blob>(
|
||||
`/api/admin/video-tasks/${encodeURIComponent(taskId)}/video`,
|
||||
{ responseType: 'blob', timeout: 0 },
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async trigger(taskKey: string, payload: Record<string, unknown> = {}): Promise<{ run_id: string; status: string }> {
|
||||
const response = await apiClient.post(`/api/admin/tasks/${taskKey}/trigger`, payload)
|
||||
return response.data
|
||||
|
||||
@@ -40,11 +40,13 @@ export interface SendVerificationCodeResponse {
|
||||
message: string
|
||||
success: boolean
|
||||
expire_minutes?: number
|
||||
verification_token: string
|
||||
}
|
||||
|
||||
export interface VerifyEmailRequest {
|
||||
email: string
|
||||
code: string
|
||||
verification_token: string
|
||||
}
|
||||
|
||||
export interface VerifyEmailResponse {
|
||||
@@ -54,6 +56,7 @@ export interface VerifyEmailResponse {
|
||||
|
||||
export interface VerificationStatusRequest {
|
||||
email: string
|
||||
verification_token: string
|
||||
}
|
||||
|
||||
export interface VerificationStatusResponse {
|
||||
@@ -72,6 +75,7 @@ export interface RegisterRequest {
|
||||
invite_code?: string
|
||||
privacy_policy_accepted?: boolean
|
||||
privacy_policy_version?: string
|
||||
email_verification_token?: string
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
@@ -141,7 +145,7 @@ export interface User {
|
||||
export const authApi = {
|
||||
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
const response = await apiClient.post<LoginResponse>('/api/auth/login', credentials)
|
||||
apiClient.setToken(response.data.access_token)
|
||||
apiClient.setToken(response.data.access_token, true)
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -184,10 +188,14 @@ export const authApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async verifyEmail(email: string, code: string): Promise<VerifyEmailResponse> {
|
||||
async verifyEmail(
|
||||
email: string,
|
||||
code: string,
|
||||
verificationToken: string
|
||||
): Promise<VerifyEmailResponse> {
|
||||
const response = await apiClient.post<VerifyEmailResponse>(
|
||||
'/api/auth/verify-email',
|
||||
{ email, code }
|
||||
{ email, code, verification_token: verificationToken }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
@@ -204,10 +212,13 @@ export const authApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getVerificationStatus(email: string): Promise<VerificationStatusResponse> {
|
||||
async getVerificationStatus(
|
||||
email: string,
|
||||
verificationToken: string
|
||||
): Promise<VerificationStatusResponse> {
|
||||
const response = await apiClient.post<VerificationStatusResponse>(
|
||||
'/api/auth/verification-status',
|
||||
{ email }
|
||||
{ email, verification_token: verificationToken }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -55,12 +55,14 @@ export interface GatewayTestResponse {
|
||||
|
||||
export interface WalletCreditEntitlement {
|
||||
type: 'wallet_credit'
|
||||
replacement_group?: string
|
||||
amount_usd: number
|
||||
balance_bucket?: WalletCreditBucket
|
||||
}
|
||||
|
||||
export interface DailyQuotaEntitlement {
|
||||
type: 'daily_quota'
|
||||
replacement_group?: string
|
||||
daily_quota_usd: number
|
||||
reset_timezone?: string
|
||||
carry_over?: boolean
|
||||
@@ -69,13 +71,41 @@ export interface DailyQuotaEntitlement {
|
||||
|
||||
export interface MembershipGroupEntitlement {
|
||||
type: 'membership_group'
|
||||
replacement_group?: string
|
||||
grant_user_groups: string[]
|
||||
}
|
||||
|
||||
export type UsagePolicyMetric = 'request_count' | 'concurrency' | 'actual_cost_usd'
|
||||
export type UsagePolicyEnforcement = 'hard_cap'
|
||||
|
||||
export type UsagePolicyWindow =
|
||||
| { kind: 'rolling'; seconds: number }
|
||||
| { kind: 'calendar_day'; timezone?: string }
|
||||
| { kind: 'calendar_week'; timezone?: string; week_start?: number }
|
||||
| { kind: 'calendar_month'; timezone?: string }
|
||||
| { kind: 'subscription_period' }
|
||||
| { kind: 'concurrent' }
|
||||
|
||||
export interface UsagePolicyRule {
|
||||
metric: UsagePolicyMetric
|
||||
window: UsagePolicyWindow
|
||||
limit: number
|
||||
enforcement?: UsagePolicyEnforcement
|
||||
}
|
||||
|
||||
export interface UsagePolicyEntitlement {
|
||||
type: 'usage_policy'
|
||||
policy_id?: string
|
||||
name?: string
|
||||
replacement_group?: string
|
||||
rules: UsagePolicyRule[]
|
||||
}
|
||||
|
||||
export type BillingEntitlement =
|
||||
| WalletCreditEntitlement
|
||||
| DailyQuotaEntitlement
|
||||
| MembershipGroupEntitlement
|
||||
| UsagePolicyEntitlement
|
||||
|
||||
export interface BillingPlan {
|
||||
id: string
|
||||
|
||||
+128
-43
@@ -10,6 +10,16 @@ import { cache } from '@/utils/cache'
|
||||
// 在开发环境下使用代理,生产环境使用环境变量
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || ''
|
||||
export const AUTH_STATE_CHANGE_EVENT = 'aether-auth-state-change'
|
||||
export const AUTH_SESSION_SIGNAL_KEY = 'aether_auth_session_signal'
|
||||
|
||||
export type AuthStateChangeDetail = {
|
||||
authenticated: boolean
|
||||
}
|
||||
|
||||
export type AuthSessionSignal = AuthStateChangeDetail & {
|
||||
eventId: string
|
||||
emittedAt: number
|
||||
}
|
||||
|
||||
type MockRuntime = typeof import('@/mocks')
|
||||
|
||||
@@ -48,6 +58,14 @@ function isAuthRequest(url?: string): boolean {
|
||||
return url?.includes('/auth/login') || url?.includes('/auth/refresh') || url?.includes('/auth/logout') || false
|
||||
}
|
||||
|
||||
function isProtectedOperationalEndpoint(url?: string): boolean {
|
||||
if (!url) return false
|
||||
const path = url.split('?', 1)[0]
|
||||
return path === '/_gateway/metrics' ||
|
||||
path.startsWith('/_gateway/audit/') ||
|
||||
path.startsWith('/_gateway/async-tasks/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 403 错误是否表示用户账号级别的问题(需要清除认证并跳转)
|
||||
*/
|
||||
@@ -100,17 +118,11 @@ function createDemoAdapter(defaultAdapter: AxiosAdapter) {
|
||||
class ApiClient {
|
||||
private client: AxiosInstance
|
||||
private token: string | null = null
|
||||
private authStateVersion = 0
|
||||
private isRefreshing = false
|
||||
private refreshPromise: Promise<string> | null = null
|
||||
private readonly refreshCoordinator = new CrossTabRefreshCoordinator()
|
||||
|
||||
private readonly onStorageSync = (event: StorageEvent): void => {
|
||||
if (event.key !== 'access_token') {
|
||||
return
|
||||
}
|
||||
this.syncTokenState(event.newValue)
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@@ -126,7 +138,7 @@ class ApiClient {
|
||||
this.client.defaults.adapter = createDemoAdapter(defaultAdapter)
|
||||
|
||||
this.setupInterceptors()
|
||||
this.setupCrossTabAuthSync()
|
||||
this.purgeLegacyStoredTokens()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,12 +148,15 @@ class ApiClient {
|
||||
// 请求拦截器 - 仅处理认证
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
if (config.url?.includes('/api/')) {
|
||||
const carriesSessionCredentials = config.url?.includes('/api/') ||
|
||||
isProtectedOperationalEndpoint(config.url)
|
||||
|
||||
if (carriesSessionCredentials) {
|
||||
config.headers['X-Client-Device-Id'] = getClientDeviceId()
|
||||
}
|
||||
|
||||
const requiresAuth = !isPublicEndpoint(config.url, config.method) &&
|
||||
config.url?.includes('/api/')
|
||||
carriesSessionCredentials
|
||||
|
||||
if (requiresAuth) {
|
||||
const token = this.getToken()
|
||||
@@ -161,23 +176,51 @@ class ApiClient {
|
||||
)
|
||||
}
|
||||
|
||||
private setupCrossTabAuthSync(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', this.onStorageSync)
|
||||
}
|
||||
}
|
||||
|
||||
private emitAuthStateChange(token: string | null): void {
|
||||
private emitAuthStateChange(authenticated: boolean): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<{ token: string | null }>(AUTH_STATE_CHANGE_EVENT, {
|
||||
detail: { token },
|
||||
new CustomEvent<AuthStateChangeDetail>(AUTH_STATE_CHANGE_EVENT, {
|
||||
detail: { authenticated },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private publishAuthSessionSignal(authenticated: boolean): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const signal: AuthSessionSignal = {
|
||||
authenticated,
|
||||
eventId: typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
emittedAt: Date.now(),
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(AUTH_SESSION_SIGNAL_KEY, JSON.stringify(signal))
|
||||
} catch {
|
||||
// Cross-tab notification is best effort. The HttpOnly cookie remains the
|
||||
// source of truth when another tab starts or makes its next request.
|
||||
}
|
||||
}
|
||||
|
||||
private purgeLegacyStoredTokens(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
for (const storage of [window.localStorage, window.sessionStorage]) {
|
||||
try {
|
||||
storage.removeItem('access_token')
|
||||
} catch {
|
||||
// Storage may be disabled; the token still only lives in memory.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理响应错误
|
||||
*/
|
||||
@@ -274,23 +317,21 @@ class ApiClient {
|
||||
originalRequest: InternalAxiosRequestConfig,
|
||||
originalError: import('axios').AxiosError
|
||||
): Promise<AxiosResponse> {
|
||||
this.isRefreshing = true
|
||||
this.refreshPromise = this.coordinatedRefresh()
|
||||
|
||||
try {
|
||||
const accessToken = await this.refreshPromise
|
||||
this.setToken(accessToken)
|
||||
this.isRefreshing = false
|
||||
this.refreshPromise = null
|
||||
const accessToken = await this.restoreSession()
|
||||
|
||||
// 重试原始请求
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
||||
return this.client.request(originalRequest)
|
||||
} catch (refreshError: unknown) {
|
||||
log.error('Token refresh failed', refreshError instanceof Error ? refreshError.message : String(refreshError))
|
||||
this.isRefreshing = false
|
||||
this.refreshPromise = null
|
||||
this.clearAuth()
|
||||
const status = axios.isAxiosError(refreshError) ? refreshError.response?.status : undefined
|
||||
// Network errors and refresh-rotation conflicts do not prove that the
|
||||
// current access token or another tab's newly rotated session is invalid.
|
||||
// Only an authoritative refresh rejection signs the browser out.
|
||||
if (status === 401 || status === 403) {
|
||||
this.clearAuth()
|
||||
}
|
||||
return Promise.reject(originalError)
|
||||
}
|
||||
}
|
||||
@@ -314,32 +355,59 @@ class ApiClient {
|
||||
currentMockUserToken = token
|
||||
}
|
||||
|
||||
setToken(token: string): void {
|
||||
setToken(token: string, notifyOtherTabs = false): void {
|
||||
this.purgeLegacyStoredTokens()
|
||||
this.authStateVersion += 1
|
||||
if (this.token === token) {
|
||||
cache.clear()
|
||||
}
|
||||
this.syncTokenState(token)
|
||||
localStorage.setItem('access_token', token)
|
||||
this.emitAuthStateChange(true)
|
||||
if (notifyOtherTabs) {
|
||||
this.publishAuthSessionSignal(true)
|
||||
}
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
if (!this.token) {
|
||||
this.syncTokenState(localStorage.getItem('access_token'))
|
||||
}
|
||||
return this.token
|
||||
}
|
||||
|
||||
clearAuth(): void {
|
||||
const hadAuth = this.token !== null || localStorage.getItem('access_token') !== null
|
||||
if (hadAuth && this.token === null) {
|
||||
cache.clear()
|
||||
}
|
||||
clearAuth(notifyOtherTabs = true, emitLocalEvent = true): void {
|
||||
const hadAuth = this.token !== null
|
||||
this.authStateVersion += 1
|
||||
this.syncTokenState(null)
|
||||
localStorage.removeItem('access_token')
|
||||
// 同标签页内清理认证状态时不会触发 storage 事件,这里主动广播一次。
|
||||
if (hadAuth) {
|
||||
this.emitAuthStateChange(null)
|
||||
this.purgeLegacyStoredTokens()
|
||||
if (emitLocalEvent && hadAuth) {
|
||||
this.emitAuthStateChange(false)
|
||||
}
|
||||
if (notifyOtherTabs) {
|
||||
this.publishAuthSessionSignal(false)
|
||||
}
|
||||
}
|
||||
|
||||
async restoreSession(notifyOtherTabs = false): Promise<string> {
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise
|
||||
}
|
||||
|
||||
this.isRefreshing = true
|
||||
const requestAuthStateVersion = this.authStateVersion
|
||||
let restorePromise!: Promise<string>
|
||||
restorePromise = (async () => {
|
||||
const accessToken = await this.coordinatedRefresh()
|
||||
if (requestAuthStateVersion !== this.authStateVersion) {
|
||||
throw new Error('Auth state changed during session restore')
|
||||
}
|
||||
this.setToken(accessToken, notifyOtherTabs)
|
||||
return accessToken
|
||||
})().finally(() => {
|
||||
if (this.refreshPromise === restorePromise) {
|
||||
this.refreshPromise = null
|
||||
this.isRefreshing = false
|
||||
}
|
||||
})
|
||||
this.refreshPromise = restorePromise
|
||||
return restorePromise
|
||||
}
|
||||
|
||||
async refreshToken(): Promise<AxiosResponse> {
|
||||
@@ -372,4 +440,21 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAuthSessionSignal(raw: string | null): AuthSessionSignal | null {
|
||||
if (!raw) return null
|
||||
try {
|
||||
const signal = JSON.parse(raw) as Partial<AuthSessionSignal>
|
||||
if (
|
||||
typeof signal.authenticated !== 'boolean' ||
|
||||
typeof signal.eventId !== 'string' ||
|
||||
typeof signal.emittedAt !== 'number'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return signal as AuthSessionSignal
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default new ApiClient()
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface ProxyConfig {
|
||||
password?: string
|
||||
node_id?: string // 代理节点 ID(aether-tunnel 注册的节点,与 url 互斥)
|
||||
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
|
||||
has_credentials?: boolean // 管理端脱敏响应:代理包含未返回的认证信息
|
||||
}
|
||||
|
||||
export interface OAuthOrganizationInfo {
|
||||
@@ -31,6 +32,7 @@ export interface HeaderRuleSet {
|
||||
action: 'set'
|
||||
key: string
|
||||
value: string
|
||||
has_value?: boolean // value="***" 时表示保留服务端已有值
|
||||
}
|
||||
|
||||
export interface HeaderRuleDrop {
|
||||
@@ -60,6 +62,7 @@ export interface BodyRuleSet {
|
||||
action: 'set'
|
||||
path: string
|
||||
value: unknown
|
||||
has_value?: boolean // value="***" 时表示保留服务端已有值
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,6 +98,7 @@ export interface BodyRuleAppend {
|
||||
action: 'append'
|
||||
path: string
|
||||
value: unknown
|
||||
has_value?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,6 +113,7 @@ export interface BodyRuleInsert {
|
||||
path: string
|
||||
index: number
|
||||
value: unknown
|
||||
has_value?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +130,8 @@ export interface BodyRuleRegexReplace {
|
||||
path: string
|
||||
pattern: string
|
||||
replacement: string
|
||||
has_pattern?: boolean
|
||||
has_replacement?: boolean
|
||||
flags?: string
|
||||
count?: number
|
||||
}
|
||||
@@ -140,6 +147,7 @@ export interface BodyRuleConditionLeaf {
|
||||
path: string
|
||||
op: BodyRuleConditionOp
|
||||
value?: unknown // exists / not_exists 不需要 value
|
||||
has_value?: boolean // value="***" 时表示保留服务端已有条件值
|
||||
source?: 'body' | 'current' | 'original' | 'request_headers' | 'headers'
|
||||
}
|
||||
|
||||
|
||||
@@ -234,6 +234,7 @@ export const meApi = {
|
||||
// 更新个人信息
|
||||
async updateProfile(data: {
|
||||
email?: string
|
||||
email_verification_token?: string
|
||||
username?: string
|
||||
feature_settings?: FeatureSettingsMap | null
|
||||
}): Promise<{ message: string }> {
|
||||
|
||||
@@ -99,9 +99,9 @@ export const oauthApi = {
|
||||
return response.data.links || []
|
||||
},
|
||||
|
||||
async createBindToken(providerType: string): Promise<string> {
|
||||
const response = await apiClient.post<{ bind_token: string }>(`/api/user/oauth/${providerType}/bind-token`)
|
||||
return response.data.bind_token
|
||||
async createBindAuthorization(providerType: string): Promise<string> {
|
||||
const response = await apiClient.post<{ authorize_url: string }>(`/api/user/oauth/${providerType}/bind-token`)
|
||||
return response.data.authorize_url
|
||||
},
|
||||
|
||||
async unbind(providerType: string): Promise<{ message: string }> {
|
||||
|
||||
@@ -22,10 +22,10 @@ export interface ProxyNode {
|
||||
tunnel_mode: boolean
|
||||
tunnel_connected: boolean
|
||||
tunnel_connected_at: string | null
|
||||
// 手动节点专用字段。列表接口返回脱敏密码,详情接口返回明文密码。
|
||||
// 手动节点专用字段。密码永不通过节点接口返回,仅提供是否已配置的状态。
|
||||
proxy_url?: string
|
||||
proxy_username?: string
|
||||
proxy_password?: string
|
||||
has_proxy_password?: boolean
|
||||
// 硬件信息(aether-tunnel 节点)
|
||||
hardware_info: Record<string, unknown> | null
|
||||
estimated_max_concurrency: number | null
|
||||
|
||||
@@ -124,6 +124,7 @@ export interface PaymentOrder {
|
||||
fulfillment_error?: string | null
|
||||
gateway_order_id: string | null
|
||||
gateway_response: Record<string, unknown> | null
|
||||
has_gateway_response?: boolean
|
||||
status: string
|
||||
created_at: string
|
||||
paid_at: string | null
|
||||
@@ -145,7 +146,7 @@ export interface RefundRequest {
|
||||
gateway_refund_id: string | null
|
||||
payout_method: string | null
|
||||
payout_reference: string | null
|
||||
payout_proof: Record<string, unknown> | null
|
||||
payout_proof?: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
processed_at: string | null
|
||||
@@ -160,6 +161,7 @@ export interface WalletRechargeCreateRequest {
|
||||
pay_amount?: number
|
||||
pay_currency?: string
|
||||
exchange_rate?: number
|
||||
idempotency_key?: string
|
||||
}
|
||||
|
||||
export interface WalletRechargeOption {
|
||||
@@ -177,9 +179,6 @@ export interface WalletRechargeOption {
|
||||
export interface WalletRefundCreateRequest {
|
||||
amount_usd: number
|
||||
payment_order_id?: string
|
||||
source_type?: string
|
||||
source_id?: string
|
||||
refund_mode?: string
|
||||
reason?: string
|
||||
idempotency_key?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user