mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07: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:
@@ -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
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user