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:
elky
2026-09-04 03:45:52 +08:00
parent ddcbeb3ae9
commit 579f2c7cc1
1019 changed files with 190437 additions and 26080 deletions
+44 -1
View File
@@ -1,10 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const { logoutMock, getTokenMock, getCurrentUserMock } = vi.hoisted(() => ({
const { logoutMock, getTokenMock, getCurrentUserMock, restoreSessionMock, clearAuthMock } = vi.hoisted(() => ({
logoutMock: vi.fn(),
getTokenMock: vi.fn(() => null),
getCurrentUserMock: vi.fn(),
restoreSessionMock: vi.fn(),
clearAuthMock: vi.fn(),
}))
vi.mock('@/api/auth', () => ({
@@ -17,6 +19,8 @@ vi.mock('@/api/auth', () => ({
vi.mock('@/api/client', () => ({
default: {
getToken: getTokenMock,
restoreSession: restoreSessionMock,
clearAuth: clearAuthMock,
},
}))
@@ -28,7 +32,10 @@ describe('auth store logout', () => {
logoutMock.mockReset()
getTokenMock.mockReset()
getCurrentUserMock.mockReset()
restoreSessionMock.mockReset()
clearAuthMock.mockReset()
getTokenMock.mockReturnValue(null)
restoreSessionMock.mockRejectedValue(new Error('no refresh session'))
})
it('waits for backend logout before resolving', async () => {
@@ -68,6 +75,42 @@ describe('auth store logout', () => {
expect(settled).toBe(true)
})
it('restores the access token from the HttpOnly refresh session only once', async () => {
restoreSessionMock.mockImplementation(async () => {
getTokenMock.mockReturnValue('restored-access-token')
return 'restored-access-token'
})
getTokenMock.mockReturnValue(null)
const store = useAuthStore()
await expect(store.restoreSession()).resolves.toBe(true)
await expect(store.restoreSession()).resolves.toBe(true)
expect(store.token).toBe('restored-access-token')
expect(restoreSessionMock).toHaveBeenCalledTimes(1)
})
it('does not repeatedly probe a missing refresh session', async () => {
const store = useAuthStore()
await expect(store.restoreSession()).resolves.toBe(false)
await expect(store.restoreSession()).resolves.toBe(false)
expect(restoreSessionMock).toHaveBeenCalledTimes(1)
expect(clearAuthMock).toHaveBeenCalledWith(false, false)
})
it('preserves an existing access token when a forced restore fails', async () => {
getTokenMock.mockReturnValue('still-valid-access-token')
restoreSessionMock.mockRejectedValue(new Error('temporary refresh conflict'))
const store = useAuthStore()
await expect(store.restoreSession(true)).resolves.toBe(false)
expect(store.token).toBe('still-valid-access-token')
expect(clearAuthMock).not.toHaveBeenCalled()
})
it('clears local auth state for external logout without calling backend', () => {
const store = useAuthStore()
store.user = {
+75 -11
View File
@@ -9,13 +9,12 @@ import { getErrorStatus } from '@/types/api-error'
export const useAuthStore = defineStore('auth', () => {
const CURRENT_USER_FAILURE_BACKOFF_MS = 15_000
// 初始化时从 localStorage 恢复 token
const storedToken = apiClient.getToken()
const user = ref<User | null>(null)
const token = ref<string | null>(storedToken)
const token = ref<string | null>(apiClient.getToken())
const loading = ref(false)
const error = ref<string | null>(null)
let sessionRestoreAttempted = false
let sessionRestorePromise: Promise<boolean> | null = null
let fetchCurrentUserPromise: Promise<User | null> | null = null
let fetchCurrentUserToken: string | null = null
let lastCurrentUserFailureAt = 0
@@ -33,15 +32,11 @@ export const useAuthStore = defineStore('auth', () => {
}
const isAuthenticated = computed(() => {
// 使用 store 中的 token 状态判断认证状态
// 如果需要同步 localStorage,应该在 checkAuth 或专门的 syncToken 方法中处理
// The access token only exists in this tab's memory.
return !!token.value
})
/**
* 同步 localStorage 中的 token 到 store
* 用于处理多标签页或外部 token 变更的情况
*/
/** Synchronize the store with the access token held in this tab's memory. */
function syncToken() {
const currentToken = apiClient.getToken()
if (token.value !== currentToken) {
@@ -49,6 +44,54 @@ export const useAuthStore = defineStore('auth', () => {
markAuthStateChanged()
}
}
async function restoreSession(force = false, notifyOtherTabs = false): Promise<boolean> {
syncToken()
if (token.value && !force) {
return true
}
if (sessionRestorePromise) {
return sessionRestorePromise
}
if (sessionRestoreAttempted && !force) {
return false
}
sessionRestoreAttempted = true
const requestAuthStateVersion = authStateVersion
const requestToken = token.value
let request!: Promise<boolean>
request = (async () => {
try {
const accessToken = await apiClient.restoreSession(notifyOtherTabs)
if (requestAuthStateVersion !== authStateVersion) {
return token.value === accessToken
}
token.value = accessToken
markAuthStateChanged()
return true
} catch {
if (requestAuthStateVersion === authStateVersion && token.value === requestToken) {
// A forced refresh can race with another tab or fail transiently.
// Preserve an already-issued in-memory access token; its next
// authenticated request remains the authority on whether it is valid.
if (!requestToken) {
apiClient.clearAuth(false, false)
token.value = null
user.value = null
}
}
return false
} finally {
if (sessionRestorePromise === request) {
sessionRestorePromise = null
}
}
})()
sessionRestorePromise = request
return request
}
const isAdmin = computed(() => user.value?.role === 'admin')
const isAuditAdmin = computed(() => user.value?.role === 'audit_admin')
const canAccessAdmin = computed(() => isAdmin.value || isAuditAdmin.value)
@@ -61,6 +104,7 @@ export const useAuthStore = defineStore('auth', () => {
try {
const response = await authApi.login({ email, password, auth_type: authType })
token.value = response.access_token
sessionRestoreAttempted = true
markAuthStateChanged()
// 获取用户信息
@@ -94,6 +138,7 @@ export const useAuthStore = defineStore('auth', () => {
user.value = null
token.value = null
markAuthStateChanged()
sessionRestoreAttempted = true
await authApi.logout()
}
@@ -102,6 +147,23 @@ export const useAuthStore = defineStore('auth', () => {
token.value = null
error.value = null
markAuthStateChanged()
sessionRestoreAttempted = true
apiClient.clearAuth(false, false)
}
async function applyExternalLogin(): Promise<boolean> {
user.value = null
token.value = null
error.value = null
markAuthStateChanged()
apiClient.clearAuth(false, false)
sessionRestoreAttempted = false
const restored = await restoreSession(true)
if (!restored) {
return false
}
await fetchCurrentUser()
return !!user.value
}
function fetchCurrentUser(): Promise<User | null> {
@@ -167,7 +229,7 @@ export const useAuthStore = defineStore('auth', () => {
}
async function checkAuth() {
syncToken()
await restoreSession()
if (token.value && !user.value) {
// 即使获取用户信息失败,也保留 token。
await fetchCurrentUser()
@@ -187,8 +249,10 @@ export const useAuthStore = defineStore('auth', () => {
login,
logout,
applyExternalLogout,
applyExternalLogin,
fetchCurrentUser,
checkAuth,
restoreSession,
syncToken
}
})