Fix auth security: add request timeout, safe redirects, and memory-only token support.

This commit is contained in:
2026-03-18 14:55:37 +08:00
parent 2b1a2f0c28
commit 257668f3f3
6 changed files with 126 additions and 83 deletions
+4 -15
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import Brand from '@/components/Brand.vue' import Brand from '@/components/Brand.vue'
import { useUserStore } from '@/stores/user'
import { import {
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
@@ -25,6 +26,7 @@ interface MenuItem {
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const userStore = useUserStore()
// 侧边栏折叠状态,持久化到 localStorage // 侧边栏折叠状态,持久化到 localStorage
const collapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true') const collapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true')
@@ -75,18 +77,7 @@ const menuItems: MenuItem[] = [
{ key: '/mq-status', icon: MonitorOutlined, label: '队列监控' }, { key: '/mq-status', icon: MonitorOutlined, label: '队列监控' },
] ]
// 用户信息(P0.3 完成后将由 user store 提供) const username = computed(() => userStore.username || 'admin')
const username = computed(() => {
try {
const saved = localStorage.getItem('user')
if (saved) {
return JSON.parse(saved).username || 'admin'
}
} catch {
// ignore parse error
}
return 'admin'
})
const handleMenuClick = ({ key }: { key: string }) => { const handleMenuClick = ({ key }: { key: string }) => {
if (key.startsWith('/')) { if (key.startsWith('/')) {
@@ -95,9 +86,7 @@ const handleMenuClick = ({ key }: { key: string }) => {
} }
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('access_token') userStore.logout()
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
router.push('/login') router.push('/login')
} }
+5
View File
@@ -7,6 +7,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes' import { routes } from 'vue-router/auto-routes'
import App from './App.vue' import App from './App.vue'
import { setTokenGetter } from './utils/request'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@@ -19,6 +20,10 @@ const app = createApp(App)
app.use(pinia) app.use(pinia)
app.use(router) app.use(router)
// 注入 token 获取函数,使 request.ts 能读取内存中的 tokenremember=false 场景)
import { useUserStore } from './stores/user'
setTokenGetter(() => useUserStore().token)
// 路由守卫 // 路由守卫
const authWhitelist = ['/login', '/register'] const authWhitelist = ['/login', '/register']
+17 -14
View File
@@ -2,6 +2,9 @@
import Brand from '@/components/Brand.vue' import Brand from '@/components/Brand.vue'
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue' import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { api } from '@/utils/request'
import { ApiError } from '@/types/api'
import type { UserInfo } from '@/stores/user'
const formState = reactive({ const formState = reactive({
username: '', username: '',
@@ -29,33 +32,33 @@ const handleSubmit = async () => {
await formRef.value.validate() await formRef.value.validate()
loading.value = true loading.value = true
const response = await fetch('/api/auth/login', { const data = await api.post<{
method: 'POST', access_token: string
headers: { 'Content-Type': 'application/json' }, refresh_token: string
body: JSON.stringify({ user: UserInfo
}>('/api/auth/login', {
username: formState.username, username: formState.username,
password: formState.password, password: formState.password,
}),
}) })
const data = await response.json() userStore.setToken(data.access_token, data.refresh_token, formState.remember)
userStore.setUser(data.user)
if (data.code === 0) {
userStore.setToken(data.data.access_token, data.data.refresh_token)
userStore.setUser(data.data.user)
message.success('登录成功!') message.success('登录成功!')
// 校验 redirect 防止开放重定向漏洞
const redirect = (route.query.redirect as string) || '/' const redirect = (route.query.redirect as string) || '/'
const safeRedirect = redirect.startsWith('/') && !redirect.includes('//') ? redirect : '/'
setTimeout(() => { setTimeout(() => {
router.push(redirect) router.push(safeRedirect)
}, 500) }, 500)
} else {
message.error(data.message || '登录失败,请重试')
}
} catch (error: unknown) { } catch (error: unknown) {
if (error && typeof error === 'object' && 'errorFields' in error) { if (error && typeof error === 'object' && 'errorFields' in error) {
return return
} }
if (error instanceof ApiError) {
message.error(error.message)
return
}
console.error('登录错误:', error) console.error('登录错误:', error)
message.error('登录失败,请检查网络连接') message.error('登录失败,请检查网络连接')
} finally { } finally {
+16 -15
View File
@@ -2,6 +2,9 @@
import Brand from '@/components/Brand.vue' import Brand from '@/components/Brand.vue'
import { UserOutlined, LockOutlined, MailOutlined } from '@ant-design/icons-vue' import { UserOutlined, LockOutlined, MailOutlined } from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { api } from '@/utils/request'
import { ApiError } from '@/types/api'
import type { UserInfo } from '@/stores/user'
const formState = reactive({ const formState = reactive({
username: '', username: '',
@@ -47,24 +50,21 @@ const handleSubmit = async () => {
await formRef.value.validate() await formRef.value.validate()
loading.value = true loading.value = true
const response = await fetch('/api/auth/register', { const data = await api.post<{
method: 'POST', access_token?: string
headers: { 'Content-Type': 'application/json' }, refresh_token?: string
body: JSON.stringify({ user?: UserInfo
}>('/api/auth/register', {
username: formState.username, username: formState.username,
email: formState.email, email: formState.email,
password: formState.password, password: formState.password,
}),
}) })
const data = await response.json()
if (data.code === 0) {
// 注册成功后自动设置 token(如果后端返回) // 注册成功后自动设置 token(如果后端返回)
if (data.data?.access_token) { if (data?.access_token) {
userStore.setToken(data.data.access_token, data.data.refresh_token) userStore.setToken(data.access_token, data.refresh_token!)
if (data.data.user) { if (data.user) {
userStore.setUser(data.data.user) userStore.setUser(data.user)
} }
} }
@@ -72,13 +72,14 @@ const handleSubmit = async () => {
setTimeout(() => { setTimeout(() => {
router.push('/login') router.push('/login')
}, 1500) }, 1500)
} else {
message.error(data.message || '注册失败,请重试')
}
} catch (error: unknown) { } catch (error: unknown) {
if (error && typeof error === 'object' && 'errorFields' in error) { if (error && typeof error === 'object' && 'errorFields' in error) {
return return
} }
if (error instanceof ApiError) {
message.error(error.message)
return
}
console.error('注册错误:', error) console.error('注册错误:', error)
message.error('注册失败,请检查网络连接') message.error('注册失败,请检查网络连接')
} finally { } finally {
+12 -2
View File
@@ -13,15 +13,25 @@ export const useUserStore = defineStore('user', () => {
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token')) const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'))
const user = ref<UserInfo | null>(null) const user = ref<UserInfo | null>(null)
const isLoggedIn = computed(() => !!token.value) // 基本 JWT 格式校验(三段式),防止垃圾值绕过路由守卫
const isLoggedIn = computed(() => {
const t = token.value
return !!t && t.split('.').length === 3
})
const isAdmin = computed(() => user.value?.role === 'admin') const isAdmin = computed(() => user.value?.role === 'admin')
const username = computed(() => user.value?.username || '') const username = computed(() => user.value?.username || '')
function setToken(accessToken: string, newRefreshToken: string) { function setToken(accessToken: string, newRefreshToken: string, remember = true) {
token.value = accessToken token.value = accessToken
refreshToken.value = newRefreshToken refreshToken.value = newRefreshToken
if (remember) {
localStorage.setItem('access_token', accessToken) localStorage.setItem('access_token', accessToken)
localStorage.setItem('refresh_token', newRefreshToken) localStorage.setItem('refresh_token', newRefreshToken)
} else {
// 不记住:清除持久化,token 仅存于内存,关闭标签页即失效
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
}
} }
function setUser(info: UserInfo) { function setUser(info: UserInfo) {
+42 -7
View File
@@ -1,11 +1,20 @@
import { ApiError } from '@/types/api' import { ApiError } from '@/types/api'
const DEFAULT_TIMEOUT = 15000
// 允许外部注入 token 获取函数,避免与 store 循环依赖
let tokenGetter: (() => string | null) | null = null
export function setTokenGetter(fn: () => string | null) {
tokenGetter = fn
}
interface RequestOptions extends RequestInit { interface RequestOptions extends RequestInit {
params?: Record<string, unknown> params?: Record<string, unknown>
timeout?: number
} }
async function request<T = unknown>(url: string, options: RequestOptions = {}): Promise<T> { async function request<T = unknown>(url: string, options: RequestOptions = {}): Promise<T> {
const token = localStorage.getItem('access_token') const token = tokenGetter?.() ?? localStorage.getItem('access_token')
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -27,21 +36,38 @@ async function request<T = unknown>(url: string, options: RequestOptions = {}):
url = `${url}${separator}${query}` url = `${url}${separator}${query}`
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars const { params: _params, timeout: _timeout, ...fetchOptions } = options
const { params: _params, ...fetchOptions } = options
const response = await fetch(url, { ...fetchOptions, headers }) // 超时控制
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), options.timeout ?? DEFAULT_TIMEOUT)
// 401 未授权 try {
const response = await fetch(url, {
...fetchOptions,
headers,
signal: controller.signal,
})
// 401 未授权:保留当前路径作为 redirect 参数
if (response.status === 401) { if (response.status === 401) {
localStorage.removeItem('access_token') localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token') localStorage.removeItem('refresh_token')
localStorage.removeItem('user') localStorage.removeItem('user')
window.location.href = '/login' const redirect = encodeURIComponent(
window.location.pathname + window.location.search,
)
window.location.href = `/login?redirect=${redirect}`
throw new ApiError('登录已过期,请重新登录', 401) throw new ApiError('登录已过期,请重新登录', 401)
} }
const result = await response.json() // 防护非 JSON 响应(如网关 HTML 错误页)
let result
try {
result = await response.json()
} catch {
throw new ApiError('服务器返回了非预期的响应格式', response.status)
}
// 业务错误 // 业务错误
if (result.code !== 0) { if (result.code !== 0) {
@@ -49,6 +75,15 @@ async function request<T = unknown>(url: string, options: RequestOptions = {}):
} }
return result.data return result.data
} catch (error) {
if (error instanceof ApiError) throw error
if (error instanceof DOMException && error.name === 'AbortError') {
throw new ApiError('请求超时,请检查网络连接', 0)
}
throw error
} finally {
clearTimeout(timeoutId)
}
} }
export const api = { export const api = {