Fix auth security: add request timeout, safe redirects, and memory-only token support.
This commit is contained in:
@@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 能读取内存中的 token(remember=false 场景)
|
||||||
|
import { useUserStore } from './stores/user'
|
||||||
|
setTokenGetter(() => useUserStore().token)
|
||||||
|
|
||||||
// 路由守卫
|
// 路由守卫
|
||||||
const authWhitelist = ['/login', '/register']
|
const authWhitelist = ['/login', '/register']
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user