Files
zclaw_openfang/admin-v2/src/services/request.ts
iven c2aff09811 feat(security): Auth Token HttpOnly Cookie — XSS 安全加固
后端:
- axum-extra 启用 cookie feature
- login/register/refresh 设置 HttpOnly + Secure + SameSite=Strict cookies
- 新增 POST /api/v1/auth/logout 清除 cookies
- auth_middleware 支持 cookie 提取路径(fallback from header)
- CORS: 添加 allow_credentials(true) + COOKIE header

前端 (admin-v2):
- authStore: token 仅存内存,不再写 localStorage(account 保留)
- request.ts: 添加 withCredentials: true 发送 cookies
- 修复 refresh token rotation bug(之前不更新 stored refreshToken)
- logout 调用后端清除 cookie 端点

向后兼容: API 客户端仍可用 Authorization: Bearer header
Desktop (Ed25519 设备认证) 完全不受影响
2026-03-30 19:30:42 +08:00

128 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// ZCLAW Admin V2 — Axios 实例 + JWT 拦截器
// ============================================================
//
// 认证策略: 主路径使用 HttpOnly cookie浏览器自动附加
// Authorization header 作为 fallback 保留用于 API 客户端。
import axios from 'axios'
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import type { AxiosRequestConfig } from 'axios'
import type { ApiError } from '@/types'
import { useAuthStore } from '@/stores/authStore'
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
const TIMEOUT_MS = 30_000
/** API 业务错误 */
export class ApiRequestError extends Error {
constructor(
public status: number,
public body: ApiError,
) {
super(body.message || `Request failed with status ${status}`)
this.name = 'ApiRequestError'
}
}
const request = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT_MS,
headers: { 'Content-Type': 'application/json' },
withCredentials: true, // 发送 HttpOnly cookies
})
// ── 请求拦截器:附加 Authorization header fallback ──────────
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// ── 响应拦截器401 自动刷新 ──────────────────────────────
let isRefreshing = false
let pendingRequests: Array<(token: string) => void> = []
function onTokenRefreshed(newToken: string) {
pendingRequests.forEach((cb) => cb(newToken))
pendingRequests = []
}
request.interceptors.response.use(
(response) => response,
async (error: AxiosError<{ error?: string; message?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
// 401 → 尝试刷新 Token
if (error.response?.status === 401 && !originalRequest._retry) {
const store = useAuthStore.getState()
if (!store.refreshToken) {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve) => {
pendingRequests.push((newToken: string) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`
resolve(request(originalRequest))
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const res = await axios.post(`${BASE_URL}/auth/refresh`, null, {
headers: { Authorization: `Bearer ${store.refreshToken}` },
withCredentials: true, // 发送 refresh cookie
})
const newToken = res.data.token as string
const newRefreshToken = res.data.refresh_token as string
// 更新内存中的 token实际认证通过 HttpOnly cookie浏览器已自动更新
store.setToken(newToken)
if (newRefreshToken) {
store.setRefreshToken(newRefreshToken)
}
onTokenRefreshed(newToken)
originalRequest.headers.Authorization = `Bearer ${newToken}`
return request(originalRequest)
} catch {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
// 构造 ApiRequestError
if (error.response) {
const body: ApiError = {
error: error.response.data?.error || 'unknown',
message: error.response.data?.message || `请求失败 (${error.response.status})`,
status: error.response.status,
}
return Promise.reject(new ApiRequestError(error.response.status, body))
}
return Promise.reject(error)
},
)
export default request
/** 将 AbortSignal 注入 Axios config用于 TanStack Query 的请求取消 */
export function withSignal(config: AxiosRequestConfig = {}, signal?: AbortSignal): AxiosRequestConfig {
if (signal) {
return { ...config, signal }
}
return config
}