From 176593a8c2a9e937ea224e839e87b469cba5bc03 Mon Sep 17 00:00:00 2001 From: wangdl Date: Sat, 4 Jul 2026 11:18:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Token=20=E5=AE=89=E5=85=A8=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=20=E2=80=94=20localStorage=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E8=87=B3=20httpOnly=20cookie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package-lock.json | 137 ++++++++++++++++++++++++++++++++++++ package.json | 2 + src/api/auth.ts | 10 +-- src/hooks/use-auth-query.ts | 25 +++---- src/lib/api-client.ts | 51 +++++--------- src/lib/token.ts | 55 +++++++++------ 6 files changed, 207 insertions(+), 73 deletions(-) diff --git a/package-lock.json b/package-lock.json index 79f44e0..91a3f08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5875ada..8e95536 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/auth.ts b/src/api/auth.ts index 069634b..d8b6306 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -7,11 +7,13 @@ export const authAPI = { login(email: string, password: string) { return api.post('/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('/admin-api/auth/logout', { refreshToken }) + logout() { + return api.post('/admin-api/auth/logout') }, me() { return api.get('/admin-api/auth/me') diff --git a/src/hooks/use-auth-query.ts b/src/hooks/use-auth-query.ts index 76238c8..feae030 100644 --- a/src/hooks/use-auth-query.ts +++ b/src/hooks/use-auth-query.ts @@ -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({ 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() }, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 308f48c..891ee21 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -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 { +interface ApiEnvelope { 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 | null = null async function tryRefresh(): Promise { - const token = getRefreshToken() - if (!token) return false - if (!refreshPromise) { refreshPromise = (async () => { try { const res = await axios.post>( '/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 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(path: string, params?: Record) { - return apiClient.get>(path, { params }).then(r => r.data) + return apiClient.get>(path, { params }).then(r => r.data) }, post(path: string, body?: unknown) { - return apiClient.post>(path, body).then(r => r.data) + return apiClient.post>(path, body).then(r => r.data) }, put(path: string, body?: unknown) { - return apiClient.put>(path, body).then(r => r.data) + return apiClient.put>(path, body).then(r => r.data) }, patch(path: string, body?: unknown) { - return apiClient.patch>(path, body).then(r => r.data) + return apiClient.patch>(path, body).then(r => r.data) }, delete(path: string, body?: unknown) { - return apiClient.delete>(path, { data: body }).then(r => r.data) + return apiClient.delete>(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) diff --git a/src/lib/token.ts b/src/lib/token.ts index 8b7695c..4681cda 100644 --- a/src/lib/token.ts +++ b/src/lib/token.ts @@ -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 | null { try { const raw = localStorage.getItem(ADMIN_USER_KEY) @@ -33,3 +20,29 @@ export function getStoredAdminUser>(): T | null { export function setStoredAdminUser>(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() +}