feat: Token 安全升级 — localStorage 迁移至 httpOnly cookie
All checks were successful
Deploy Admin Frontend / build-and-deploy (push) Successful in 12s
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:
parent
832a7f7960
commit
176593a8c2
137
package-lock.json
generated
137
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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()
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user