feat: Token 安全升级 — localStorage 迁移至 httpOnly cookie
All checks were successful
Deploy Admin Frontend / build-and-deploy (push) Successful in 12s

- api-client: withCredentials: true,移除 Authorization header 注入
- api-client: 401 刷新改为 cookie 模式,无需前端传 refreshToken
- token.ts: 废弃 localStorage token 读写,保留 admin user 缓存
- use-auth-query: 移除 setTokens/clearTokens 调用
- auth.ts: logout 不再传 refreshToken(cookie 标识会话)

Breaking: 需要后端同步部署(登录/刷新 Set-Cookie + 守卫读 cookie)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-07-04 11:18:02 +08:00
parent 832a7f7960
commit 176593a8c2
6 changed files with 207 additions and 73 deletions

137
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@tanstack/react-query": "^5.100.11",
"antd": "^5.29.3",
"axios": "^1.18.1",
"cookie-parser": "^1.4.7",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
@ -27,6 +28,7 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/cookie-parser": "^1.4.10",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@ -2162,6 +2164,39 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz",
@ -2193,6 +2228,33 @@
"@types/estree": "*"
}
},
"node_modules/@types/express": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "^2"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz",
@ -2202,6 +2264,14 @@
"@types/unist": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -2240,6 +2310,22 @@
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
"license": "MIT"
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/react": {
"version": "19.2.15",
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.15.tgz",
@ -2269,6 +2355,29 @@
"@types/react": "*"
}
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz",
@ -2977,6 +3086,34 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/copy-to-clipboard": {
"version": "3.3.3",
"resolved": "https://registry.npmmirror.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",

View File

@ -15,6 +15,7 @@
"@tanstack/react-query": "^5.100.11",
"antd": "^5.29.3",
"axios": "^1.18.1",
"cookie-parser": "^1.4.7",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
@ -29,6 +30,7 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/cookie-parser": "^1.4.10",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",

View File

@ -7,11 +7,13 @@ export const authAPI = {
login(email: string, password: string) {
return api.post<LoginResponse>('/admin-api/auth/login', { email, password })
},
refresh(refreshToken: string) {
return api.post<{ accessToken: string; refreshToken: string; adminUser: AdminUser }>('/admin-api/auth/refresh', { refreshToken })
refresh() {
return api.post<{ accessToken: string; refreshToken: string; adminUser: AdminUser }>(
'/admin-api/auth/refresh',
)
},
logout(refreshToken: string) {
return api.post<void>('/admin-api/auth/logout', { refreshToken })
logout() {
return api.post<void>('/admin-api/auth/logout')
},
me() {
return api.get<AdminUser>('/admin-api/auth/me')

View File

@ -1,17 +1,19 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { authAPI } from '@/api'
import { getAccessToken, setTokens, clearTokens, setStoredAdminUser } from '@/lib/token'
import { setStoredAdminUser, clearStoredAdminUser } from '@/lib/token'
import type { AdminUser } from '@/types/admin'
export function useAdminUserQuery() {
return useQuery<AdminUser | null>({
queryKey: ['admin', 'me'],
queryFn: async () => {
const token = getAccessToken()
if (!token) return null
const user = await authAPI.me()
setStoredAdminUser(user)
return user
try {
const user = await authAPI.me()
setStoredAdminUser(user)
return user
} catch {
return null
}
},
staleTime: 5 * 60 * 1000,
retry: false,
@ -24,7 +26,6 @@ export function useLoginMutation() {
mutationFn: ({ email, password }: { email: string; password: string }) =>
authAPI.login(email, password),
onSuccess: (data) => {
setTokens(data.accessToken, data.refreshToken)
setStoredAdminUser(data.adminUser)
queryClient.setQueryData(['admin', 'me'], data.adminUser)
},
@ -34,15 +35,9 @@ export function useLoginMutation() {
export function useLogoutMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => {
const token = localStorage.getItem('admin_refresh_token')
if (token) {
return authAPI.logout(token).catch(() => {})
}
return Promise.resolve()
},
mutationFn: () => authAPI.logout(),
onSettled: () => {
clearTokens()
clearStoredAdminUser()
queryClient.setQueryData(['admin', 'me'], null)
queryClient.clear()
},

View File

@ -1,10 +1,10 @@
import axios, { type AxiosInstance, type InternalAxiosRequestConfig, type AxiosResponse } from 'axios'
import { getAccessToken, getRefreshToken, setTokens, clearTokens } from './token'
import axios, { type AxiosInstance, type AxiosResponse } from 'axios'
import { clearStoredAdminUser } from './token'
import { getMessage, getNotification } from './antd-global'
// ── Types ──
interface ApiEnvelope<T = unknown> {
interface ApiEnvelope<T = any> {
success: boolean
data: T
message?: string
@ -27,39 +27,24 @@ export class ApiError extends Error {
const apiClient: AxiosInstance = axios.create({
baseURL: '',
timeout: 30000,
withCredentials: true,
headers: { 'Content-Type': 'application/json' },
})
// ── Request Interceptor ──
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = getAccessToken()
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// ── Response Interceptor ──
let refreshPromise: Promise<boolean> | null = null
async function tryRefresh(): Promise<boolean> {
const token = getRefreshToken()
if (!token) return false
if (!refreshPromise) {
refreshPromise = (async () => {
try {
const res = await axios.post<ApiEnvelope<{ accessToken: string; refreshToken: string }>>(
'/admin-api/auth/refresh',
{ refreshToken: token },
{},
{ withCredentials: true },
)
if (res.data.success) {
setTokens(res.data.data.accessToken, res.data.data.refreshToken)
return true
}
return false
return res.data.success
} catch {
return false
} finally {
@ -80,11 +65,11 @@ apiClient.interceptors.response.use(
getNotification().error({ message: '请求失败', description: errMsg })
return Promise.reject(new ApiError(errMsg, response.status, json.statusCode))
}
const data = json.data as any
const data = json.data as Record<string, unknown>
if (data?.message) {
getMessage().success(data.message)
getMessage().success(String(data.message))
}
response.data = json.data as any
response.data = json.data as never
return response
},
@ -99,7 +84,7 @@ apiClient.interceptors.response.use(
return apiClient(config)
}
}
clearTokens()
clearStoredAdminUser()
window.location.href = '/login'
return Promise.reject(new ApiError('登录已过期', 401))
}
@ -115,25 +100,25 @@ apiClient.interceptors.response.use(
},
)
// ── Convenience wrappers (match old api.get/api.post signature) ──
// ── Convenience wrappers ──
export const api = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get<T = any>(path: string, params?: Record<string, any>) {
return apiClient.get<any, AxiosResponse<T>>(path, { params }).then(r => r.data)
return apiClient.get<never, AxiosResponse<T>>(path, { params }).then(r => r.data)
},
post<T = any>(path: string, body?: unknown) {
return apiClient.post<any, AxiosResponse<T>>(path, body).then(r => r.data)
return apiClient.post<never, AxiosResponse<T>>(path, body).then(r => r.data)
},
put<T = any>(path: string, body?: unknown) {
return apiClient.put<any, AxiosResponse<T>>(path, body).then(r => r.data)
return apiClient.put<never, AxiosResponse<T>>(path, body).then(r => r.data)
},
patch<T = any>(path: string, body?: unknown) {
return apiClient.patch<any, AxiosResponse<T>>(path, body).then(r => r.data)
return apiClient.patch<never, AxiosResponse<T>>(path, body).then(r => r.data)
},
delete<T = any>(path: string, body?: unknown) {
return apiClient.delete<any, AxiosResponse<T>>(path, { data: body }).then(r => r.data)
return apiClient.delete<never, AxiosResponse<T>>(path, { data: body }).then(r => r.data)
},
/** Raw response — for file downloads, blobs, etc. No envelope unwrap. */
raw: {
get(path: string, responseType: 'text' | 'blob' = 'text') {
return apiClient.get(path, { responseType }).then(r => r.data)

View File

@ -1,26 +1,13 @@
const ACCESS_TOKEN_KEY = 'admin_access_token'
const REFRESH_TOKEN_KEY = 'admin_refresh_token'
/**
* Token
*
* Access token refresh token httpOnly cookie
* localStorage token
*
*/
const ADMIN_USER_KEY = 'admin_user'
export function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_TOKEN_KEY)
}
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY)
}
export function setTokens(access: string, refresh: string): void {
localStorage.setItem(ACCESS_TOKEN_KEY, access)
localStorage.setItem(REFRESH_TOKEN_KEY, refresh)
}
export function clearTokens(): void {
localStorage.removeItem(ACCESS_TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(ADMIN_USER_KEY)
}
export function getStoredAdminUser<T = Record<string, unknown>>(): T | null {
try {
const raw = localStorage.getItem(ADMIN_USER_KEY)
@ -33,3 +20,29 @@ export function getStoredAdminUser<T = Record<string, unknown>>(): T | null {
export function setStoredAdminUser<T = Record<string, unknown>>(user: T): void {
localStorage.setItem(ADMIN_USER_KEY, JSON.stringify(user))
}
export function clearStoredAdminUser(): void {
localStorage.removeItem(ADMIN_USER_KEY)
}
// ── 向后兼容(逐步废弃)──
/** @deprecated 认证 token 已迁移至 httpOnly cookie不再从 localStorage 读取 */
export function getAccessToken(): string | null {
return null
}
/** @deprecated 认证 token 已迁移至 httpOnly cookie不再从 localStorage 读取 */
export function getRefreshToken(): string | null {
return null
}
/** @deprecated 认证 token 已迁移至 httpOnly cookie不再写入 localStorage */
export function setTokens(_access: string, _refresh: string): void {
// no-op — cookies are set by backend
}
/** @deprecated 认证 token 已迁移至 httpOnly cookie */
export function clearTokens(): void {
clearStoredAdminUser()
}