diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3679acc..2c75068 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,11 +6,11 @@ diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 8bec31d..ff6cbb7 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -6,9 +6,19 @@ export function login(username: string, password: string): Promise r.data) } -/** 刷新 Token */ -export function refreshToken(refreshToken: string): Promise> { - return request.post('/auth/refresh', { refreshToken }).then(r => r.data) +/** 刷新 Token(refresh token 通过 httpOnly Cookie 自动携带) */ +export function refreshToken(): Promise> { + return request.post('/auth/refresh').then(r => r.data) +} + +/** 退出登录(清除服务端 refresh token cookie) */ +export function logout(): Promise> { + return request.post('/auth/logout').then(r => r.data) +} + +/** 当前登录用户自助修改密码 */ +export function changePassword(oldPassword: string, newPassword: string): Promise> { + return request.put('/auth/password', { oldPassword, newPassword }).then(r => r.data) } /** 获取当前登录用户信息 */ diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index d38101d..49d938e 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -3,13 +3,15 @@ * 替代旧 api.js 中的 fetch 封装 */ import axios from 'axios' -import { getToken } from '@/utils/token' +import { getToken, setToken, clearTokens } from '@/utils/token' /** 同源部署,API 基址为空 */ const request = axios.create({ baseURL: '', timeout: 30000, headers: { 'Content-Type': 'application/json' }, + // 允许跨域携带 Cookie(refresh token 使用 httpOnly Cookie) + withCredentials: true, }) /** 请求拦截器:自动附加 Bearer Token */ @@ -22,18 +24,64 @@ request.interceptors.request.use((config) => { }) /** - * 响应拦截器:401 触发 auth:unauthorized 事件 - * 与旧版 app.js 中的 Token 刷新逻辑一致 + * Token 刷新锁与等待队列: + * - 多个请求并发 401 时,只有第一个发起 /auth/refresh,其余挂起等待新 token 后重试。 + * - 刷新成功后重试原请求,调用方无感知。 + */ +let isRefreshing = false +let pendingQueue: Array<(token: string) => void> = [] + +/** + * 响应拦截器:401 自动刷新 access token 并重试原请求 + * 注意:/auth/login 与 /auth/refresh 自身的 401 不做刷新处理,由调用方自行处理,避免递归。 */ request.interceptors.response.use( (response) => response, - (error) => { - if (error.response?.status === 401) { - localStorage.removeItem('sb_token') - localStorage.removeItem('sb_user') - window.dispatchEvent(new Event('auth:unauthorized')) + async (error) => { + const { config, response } = error + const url: string = config?.url || '' + + if (response?.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) { + // 已有刷新在进行中,挂起当前请求,等待新 token 后重试 + if (isRefreshing) { + return new Promise((resolve, reject) => { + pendingQueue.push((token: string) => { + if (token) { + resolve(request(config)) + } else { + reject(new Error(response?.data?.message || error.message || '网络错误')) + } + }) + }) + } + + // 发起刷新(request 的 url 为 /auth/refresh,其 401 会被上方判断跳过,不会递归) + isRefreshing = true + try { + const res = await request.post('/auth/refresh') + const newToken = res.data?.data?.accessToken + if (newToken) { + setToken(newToken) + // 放行所有挂起请求(请求拦截器会自动附加新 token) + pendingQueue.forEach((cb) => cb(newToken)) + pendingQueue = [] + // 重试当前请求 + return request(config) + } + throw new Error('刷新令牌失败') + } catch (refreshErr) { + // 刷新失败:清除本地凭证并触发全局登出 + clearTokens() + pendingQueue.forEach((cb) => cb('')) + pendingQueue = [] + window.dispatchEvent(new Event('auth:unauthorized')) + return Promise.reject(new Error(response?.data?.message || error.message || '网络错误')) + } finally { + isRefreshing = false + } } - const message = error.response?.data?.message || error.message || '网络错误' + + const message = response?.data?.message || error.message || '网络错误' return Promise.reject(new Error(message)) } ) diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 73e60f2..b7e250d 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -50,8 +50,8 @@ const mobileMenuOpen = ref(false) // 提供给子组件全局弹窗能力 provide('openDocDetail', (docId: string) => dialog.openDetail(docId)) -function handleLogout() { - auth.logout() +async function handleLogout() { + await auth.doLogout() toast('已退出登录', 'info') router.push('/chat') } diff --git a/frontend/src/layouts/Topbar.vue b/frontend/src/layouts/Topbar.vue index bfea6e0..ea173e8 100644 --- a/frontend/src/layouts/Topbar.vue +++ b/frontend/src/layouts/Topbar.vue @@ -13,21 +13,108 @@ {{ displayName }} {{ r }} + 改密 退出 + + + + + + + + + + + + + +