前端鉴权与路由守卫实践
前端鉴权解决的是"如何管理登录态"和"如何控制页面可访问性"的问题。但必须明确:前端鉴权是用户体验层面的防护,不是安全层面的兜底——真正的安全保障永远在后端。
一、前端鉴权概述
1.1 前端鉴权能做什么
| 能力 | 说明 |
|---|---|
| 管理登录态 | Token 的存取、刷新、过期处理 |
| 控制页面访问 | 未登录跳转登录页、无权限显示 403 |
| 隐藏 UI 元素 | 根据权限显示/隐藏按钮、菜单项 |
| 无感刷新 Token | 利用 Refresh Token 在后台续期 |
| 提升用户体验 | 减少因权限不足导致的报错弹窗 |
1.2 前端鉴权不能做什么
| 局限 | 原因 |
|---|---|
| 无法阻止恶意请求 | 前端代码完全暴露,Token 可被截获后直接调用 API |
| 无法真正保护数据 | 所有接口数据最终依赖后端鉴权 |
| 无法防止重放攻击 | 前端无法验证 Token 是否被多次使用 |
| 无法保证 Token 安全 | XSS 攻击可窃取 localStorage 中的 Token |
核心认知:前端鉴权 = 防君子不防小人。前端做鉴权是为了给正常用户更好的体验(自动跳转登录、隐藏无权按钮),而不是为了拦住攻击者。攻击者可以通过浏览器 DevTools、curl、Postman 等工具绕过所有前端限制直接访问 API。
1.3 认证 vs 授权
| 概念 | 前端对应 | 说明 |
|---|---|---|
| 认证 | 是否有 Token / Token 是否过期 | 解决"你是谁" |
| 授权 | 是否有角色 / 是否有权限码 | 解决"你能做什么" |
二、常见前端鉴权方案对比
2.1 方案总览
| 方案 | 原理 | 复杂度 | 适用场景 |
|---|---|---|---|
| Token (JWT) | 登录获 Token,存客户端,请求时附带 | 低 | 前后端分离 SPA、移动端 |
| Session-Cookie | 登录后服务端存 Session,Cookie 自动携带 SessionID | 中 | 传统 SSR 服务、同域部署 |
| OAuth2.0 | 第三方授权码流程,获取 Access Token | 高 | 第三方登录(微信、GitHub) |
| SSO | 一个系统登录,多个系统共享登录态 | 高 | 企业级多应用套件 |
2.2 Token (JWT) 方案
流程:
用户 → 登录页 -> 输入账号密码
↓
POST /api/auth/login
↓
后端验证成功 → 返回 { accessToken, refreshToken }
↓
前端存储 Token → 存入 localStorage / sessionStorage / Cookie
↓
后续请求 → Authorization: Bearer <accessToken>
↓
Token 过期 → 用 refreshToken 换取新的 accessToken → 无感续期
↓
Refresh Token 也过期 → 跳转登录页
2.3 Session-Cookie 方案
流程:
用户 → 登录页 → 输入账号密码
↓
POST /api/auth/login
↓
后端验证成功 → 创建 Session → Set-Cookie: sessionId=xxx
↓
后续请求 → 浏览器自动携带 Cookie
↓
服务端校验 Session → 通过则放行
前端工作量极小,但局限明显:
| 维度 | Session-Cookie | Token (JWT) |
|---|---|---|
| 跨域 | 困难,需配置 CORS + withCredentials | 简单,Header 携带即可 |
| 移动端 | 不支持原生应用中自动携带 Cookie | 支持,手动设置 Header |
| 无状态 | 有状态(服务端存 Session) | 无状态(JWT 自包含) |
| 扩展性 | 多节点需 Redis Session 共享 | 天然支持水平扩展 |
| CSRF | 易受攻击(需额外防护) | 不易受 CSRF 攻击 |
2.4 OAuth2.0 第三方登录
授权码流程(最常用):
前端 → 点击"微信登录"
↓
跳转微信授权页 → 用户确认
↓
微信回调 → 携带 authorization_code
↓
POST /api/auth/oauth/callback?code=xxx
↓
后端用 code 向微信换取 access_token → 获取用户信息
↓
后端生成自有的 Token 返回前端 → 后续和 JWT 流程一致
⚠️ 前端只需处理跳转和回调,核心 token 交换在后端完成,不暴露第三方 AppSecret。
2.5 SSO 单点登录
原理:
用户访问 app1.com → 未登录 → 重定向到 sso.com/login
↓
用户在 sso.com 登录 → 生成全局 Token (TGT)
↓
携带 Ticket 跳回 app1.com → app1 用 Ticket 向 sso 验证
↓
app1 验证通过 → 创建局部 Token → 返回前端
↓
用户再访问 app2.com → 未登录 → 重定向到 sso.com
↓
sso.com 检查到已有 TGT → 直接签发 Ticket → 无需重新输入密码
前端关注点:处理跨域跳转时的 Token 传递,通常使用 URL 参数或 postMessage。
三、JWT 在前端的落地实践
3.1 Token 存储位置对比
| 存储位置 | 优点 | 缺点 | 安全性 |
|---|---|---|---|
| localStorage | 简单、持久化、跨标签页共享 | 易受 XSS 攻击(同源任何 JS 都可读取) | ⭐⭐ |
| sessionStorage | 关闭标签页自动清除 | 不跨标签页(用户体验差) | ⭐⭐⭐ |
| Cookie(无标志) | 请求自动携带 | XSS 可读取(默认)、CSRF 风险、跨域配置复杂 | ⭐⭐ |
| HttpOnly Cookie | JS 无法读取,防御 XSS | 前端无法主动读取 Token(判断过期困难) | ⭐⭐⭐⭐⭐ |
| 内存变量 | 最安全(XSS 无法获取) | 刷新页面丢失 | ⭐⭐⭐⭐⭐ |
推荐:
高安全场景:Access Token → 内存变量 + Refresh Token → HttpOnly Cookie
普通场景: Access Token → localStorage + Refresh Token → localStorage
纯前端校验:Access Token → localStorage(配合 CSP 防 XSS)
3.2 Access Token + Refresh Token 双 Token 机制
为什么需要双 Token:
| 单 Token | 双 Token |
|---|---|
| Token 有效期短(15 分钟)→ 体验差,频繁登录 | Access Token 短(15 分钟)+ Refresh Token 长(7 天) |
| Token 有效期长(7 天)→ 被盗风险大 | 被盗时只有短时效的 Access Token 生效 |
| 无法平滑续期 | Refresh Token 可在后台无感续期 |
时序图:
Access Token 有效期 15 分钟
Refresh Token 有效期 7 天
正常请求流程:
┌─────────────────────────────────────────┐
│ 请求 API → 200 OK(Access Token 有效) │
│ 请求 API → 200 OK(Access Token 有效) │
└─────────────────────────────────────────┘
Token 过期流程:
┌─────────────────────────────────────────┐
│ 请求 API → 401 Unauthorized │
│ ↓ │
│ 拦截器捕获 401 → 调用 refreshToken API │
│ ↓ │
│ 后端验证 Refresh Token → 返回新 Token │
│ ↓ │
│ 更新存储中的 Access Token │
│ ↓ │
│ 重放原请求 → 200 OK │
└─────────────────────────────────────────┘
Refresh Token 也过期:
┌─────────────────────────────────────────┐
│ Refresh Token 请求 → 401 Unauthorized │
│ ↓ │
│ 清除所有 Token → 跳转登录页 │
└─────────────────────────────────────────┘
3.3 axios 拦截器实现无感刷新
Vue 3 实现:
// src/utils/http.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { useUserStore } from '@/stores/user'
import router from '@/router'
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
})
// 是否正在刷新 Token 的标记
let isRefreshing = false
// 等待刷新期间排队的请求
let pendingRequests: Array<{
resolve: (value: unknown) => void
reject: (reason: unknown) => void
}> = []
/** 请求拦截器:自动携带 Token */
http.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('accessToken')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error),
)
/** 响应拦截器:无感刷新 Token */
http.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
// ❌ 不是 401 或已重放过,直接拒绝
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error)
}
// ✅ 防止并发刷新:正在刷新时,将后续请求排队
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingRequests.push({ resolve, reject })
}).then((token) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`
}
return http(originalRequest)
})
}
originalRequest._retry = true
isRefreshing = true
try {
const refreshToken = localStorage.getItem('refreshToken')
if (!refreshToken) throw new Error('No refresh token')
const res = await axios.post('/api/auth/refresh', {
refreshToken,
})
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = res.data
localStorage.setItem('accessToken', newAccessToken)
localStorage.setItem('refreshToken', newRefreshToken)
// ✅ 重放排队的请求
pendingRequests.forEach(({ resolve }) => resolve(newAccessToken))
pendingRequests = []
// ✅ 重放原请求
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
}
return http(originalRequest)
} catch (refreshError) {
// ❌ 刷新失败 → 清除状态 → 跳转登录
pendingRequests.forEach(({ reject }) => reject(refreshError))
pendingRequests = []
const userStore = useUserStore()
userStore.logout()
router.push('/login')
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
},
)
export default http
React 实现:
// src/utils/http.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { useUserStore } from '@/stores/userStore'
import { useNavigate } from 'react-router-dom'
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
})
let isRefreshing = false
let pendingRequests: Array<{
resolve: (value: unknown) => void
reject: (reason: unknown) => void
}> = []
// 注意:React 中无法在模块顶层使用 hooks,
// 通过函数注入的方式获取 navigate
let _navigate: ReturnType<typeof useNavigate> | null = null
export const injectNavigate = (navigate: ReturnType<typeof useNavigate>) => {
_navigate = navigate
}
http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('accessToken')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
http.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingRequests.push({ resolve, reject })
}).then((token) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`
}
return http(originalRequest)
})
}
originalRequest._retry = true
isRefreshing = true
try {
const refreshToken = localStorage.getItem('refreshToken')
if (!refreshToken) throw new Error('No refresh token')
const res = await axios.post('/api/auth/refresh', { refreshToken })
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = res.data
localStorage.setItem('accessToken', newAccessToken)
localStorage.setItem('refreshToken', newRefreshToken)
pendingRequests.forEach(({ resolve }) => resolve(newAccessToken))
pendingRequests = []
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
}
return http(originalRequest)
} catch (refreshError) {
pendingRequests.forEach(({ reject }) => reject(refreshError))
pendingRequests = []
useUserStore.getState().logout()
_navigate?.('/login')
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
},
)
export default http
3.4 Token 过期判断
前端解码 JWT Payload 判断是否过期:
// src/utils/jwt.ts
/**
* 解析 JWT 的 Payload(不验证签名,仅供前端展示和判断过期时间)
* ⚠️ 永远不要信任客户端解析出的数据,仅用于 UI 判断
*/
export function parseJwtPayload(token: string): Record<string, unknown> | null {
try {
const payloadBase64 = token.split('.')[1]
const payload = JSON.parse(atob(payloadBase64))
return payload
} catch {
return null
}
}
/**
* 判断 Token 是否即将过期(提前 5 分钟视为过期)
*/
export function isTokenExpiring(token: string, bufferSeconds = 300): boolean {
const payload = parseJwtPayload(token)
if (!payload?.exp) return true
const exp = payload.exp as number
const now = Math.floor(Date.now() / 1000)
return now >= exp - bufferSeconds
}
/**
* 获取 Token 剩余有效秒数
*/
export function getTokenRemainingSeconds(token: string): number {
const payload = parseJwtPayload(token)
if (!payload?.exp) return 0
const exp = payload.exp as number
const now = Math.floor(Date.now() / 1000)
return Math.max(0, exp - now)
}
典型应用场景:
// 在请求前主动判断,提前刷新
const token = localStorage.getItem('accessToken')
if (token && isTokenExpiring(token)) {
// 主动刷新 Token,而不是等 401 再刷
await refreshToken()
}
3.5 JWT 安全注意事项
| 注意事项 | 说明 |
|---|---|
| 前端不要存敏感数据 | JWT payload 只 base64 编码,任何人可解码查看 |
| 不要信任前端解析的 payload | 用户是否还有权限,必须请求后端接口确认 |
| Token 刷新后旧 Token 仍可用 | JWT 无状态,除非服务端维护黑名单 |
| 敏感操作不要只用 Token | 修改密码、转账等操作应二次验证(验证码/生物识别) |
| HTTPS 是前提 | 非 HTTPS 下 Token 明文传输,中间人攻击可窃取 |
四、Vue 路由守卫
4.1 路由配置与 meta 信息
// src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
// 声明路由 meta 类型
declare module 'vue-router' {
interface RouteMeta {
title?: string
requiresAuth?: boolean // 是否需要登录
roles?: string[] // 允许访问的角色
permissions?: string[] // 允许访问的权限码
hidden?: boolean // 是否在菜单中隐藏
icon?: string // 菜单图标
keepAlive?: boolean // 是否缓存
}
}
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/LoginView.vue'),
meta: { title: '登录', requiresAuth: false },
},
{
path: '/',
component: () => import('@/layouts/AdminLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/DashboardView.vue'),
meta: { title: '仪表盘', requiresAuth: true, roles: ['admin', 'user'] },
},
{
path: 'users',
name: 'UserManagement',
component: () => import('@/views/system/UserManagement.vue'),
meta: {
title: '用户管理',
requiresAuth: true,
roles: ['admin'],
permissions: ['system:user:list'],
icon: 'User',
},
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/system/Settings.vue'),
meta: { title: '系统设置', requiresAuth: true, roles: ['admin'] },
},
],
},
{
path: '/403',
name: 'Forbidden',
component: () => import('@/views/error/Forbidden.vue'),
meta: { title: '无权限访问' },
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/error/NotFound.vue'),
meta: { title: '页面不存在' },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
4.2 beforeEach:全局前置守卫
// src/router/guards.ts
import type { Router } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
/**
* 白名单:无需登录即可访问的路由
*/
const WHITE_LIST = ['/login', '/register', '/forgot-password', '/403', '/404']
export function setupRouterGuards(router: Router) {
router.beforeEach(async (to, _from, next) => {
// 设置页面标题
document.title = `${to.meta.title ?? ''} - Admin`
const userStore = useUserStore()
const token = userStore.token
// ===================== 未登录流程 =====================
if (!token) {
if (to.meta.requiresAuth !== false || !WHITE_LIST.includes(to.path)) {
// ❌ 需要登录但无 Token → 跳转登录页,携带重定向地址
next({ path: '/login', query: { redirect: to.fullPath } })
} else {
// ✅ 白名单路由 → 直接放行
next()
}
return
}
// ===================== 已登录流程 =====================
// 已登录时访问登录页 → 重定向到首页
if (to.path === '/login') {
next({ path: '/' })
return
}
// ===================== 角色/权限校验 =====================
const requiredRoles = to.meta.roles as string[] | undefined
const requiredPermissions = to.meta.permissions as string[] | undefined
// 无需角色校验 → 放行
if (!requiredRoles && !requiredPermissions) {
next()
return
}
// 角色校验
if (requiredRoles && requiredRoles.length > 0) {
const hasRole = requiredRoles.some((role) => userStore.roles.includes(role))
if (!hasRole) {
ElMessage.warning('您没有访问该页面的权限')
next({ path: '/403' })
return
}
}
// 权限码校验
if (requiredPermissions && requiredPermissions.length > 0) {
const hasPermission = requiredPermissions.every((perm) =>
userStore.permissions.includes(perm),
)
if (!hasPermission) {
ElMessage.warning('您没有访问该页面的权限')
next({ path: '/403' })
return
}
}
next()
})
// 后置守卫:页面加载完成后操作
router.afterEach((to) => {
// 页面访问埋点
console.log(`[Page View] ${to.path} - ${to.meta.title}`)
})
}
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { setupRouterGuards } from './router/guards'
const app = createApp(App)
app.use(router)
setupRouterGuards(router)
app.mount('#app')
4.3 beforeEnter:路由独享守卫
适用于某个路由特殊的权限校验逻辑。
{
path: 'articles/:id',
name: 'ArticleDetail',
component: () => import('@/views/content/ArticleDetail.vue'),
meta: { title: '文章详情', requiresAuth: true },
beforeEnter: (to, _from, next) => {
const id = Number(to.params.id)
// ❌ 防御式校验:参数不合法则重定向
if (Number.isNaN(id) || id <= 0) {
next({ path: '/404' })
return
}
// 检查文章是否可访问(实际场景应异步请求后端)
// ...
next()
},
}
4.4 动态路由:根据权限添加
// src/router/dynamic-routes.ts
import type { RouteRecordRaw } from 'vue-router'
/**
* 根据后端返回的菜单/权限配置动态生成路由
*/
export function generateDynamicRoutes(menus: ApiMenu[]): RouteRecordRaw[] {
return menus
.filter((menu) => menu.visible !== false) // 过滤隐藏菜单
.map((menu) => ({
path: menu.path,
name: menu.name,
component: () => import(`@/views/${menu.component}.vue`), // 约定路径映射
meta: {
title: menu.title,
icon: menu.icon,
roles: menu.roles,
permissions: menu.permissions,
hidden: menu.hidden,
},
children: menu.children ? generateDynamicRoutes(menu.children) : undefined,
}))
}
// 在 beforeEach 中使用
router.beforeEach(async (to, _from, next) => {
const userStore = useUserStore()
const token = userStore.token
// 已登录但还没加载动态路由
if (token && !userStore.dynamicRoutesLoaded) {
try {
// 1. 获取用户菜单/权限
const menus = await userStore.fetchUserMenus()
// 2. 生成动态路由并添加
const dynamicRoutes = generateDynamicRoutes(menus)
dynamicRoutes.forEach((route) => {
router.addRoute(route) // 添加到根路由
})
// 3. 标记已加载
userStore.dynamicRoutesLoaded = true
// 4. 重定向到目标路由(addRoute 后需要重定向才能匹配新路由)
next({ ...to, replace: true })
} catch {
// 获取菜单失败 → 退出登录
userStore.logout()
next({ path: '/login' })
}
return
}
next()
})
⚠️ 动态路由注意事项:
| 注意事项 | 说明 |
|---|---|
| addRoute 后需重定向 | 新添加的路由不会立即匹配当前导航,需 next({ ...to, replace: true }) |
| 路由名称冲突 | 添加同名路由会覆盖,建议使用唯一前缀 |
| 避免重复添加 | 用 router.hasRoute(name) 判断是否已注册 |
| 路由组件路径约定 | 约定优于配置,菜单的 component 字段对应 views 下的路径 |
4.5 路由守卫执行流程
导航被触发
↓
beforeRouteLeave(失活的组件)
↓
beforeEach(全局前置守卫)
↓
beforeEnter(路由独享守卫,只有当前路由有)
↓
beforeResolve(全局解析守卫)
↓
导航被确认
↓
afterEach(全局后置守卫)
↓
beforeRouteEnter(进入的组件)
↓
组件创建 → DOM 更新
五、React 路由守卫
5.1 ProtectedRoute 包装组件
// src/components/ProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router-dom'
import { useUserStore } from '@/stores/userStore'
import { Result, Button } from 'antd'
interface ProtectedRouteProps {
children: React.ReactNode
/** 允许访问的角色 */
roles?: string[]
/** 需要的权限码 */
permissions?: string[]
}
/**
* 路由守卫包装组件
*
* 用法:
* <Route path="/dashboard" element={<ProtectedRoute roles={['admin']}><Dashboard /></ProtectedRoute>} />
*/
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
roles,
permissions,
}) => {
const { token, roles: userRoles, permissions: userPermissions } = useUserStore()
const location = useLocation()
// ===================== 1. 未登录 → 重定向到登录页 =====================
if (!token) {
return <Navigate to="/login" state={{ from: location }} replace />
}
// ===================== 2. 角色校验 =====================
if (roles && roles.length > 0) {
const hasRole = roles.some((role) => userRoles.includes(role))
if (!hasRole) {
return (
<Result
status="403"
title="403"
subTitle="抱歉,您没有访问此页面的权限。"
extra={
<Button type="primary" onClick={() => window.history.back()}>
返回上一页
</Button>
}
/>
)
}
}
// ===================== 3. 权限码校验 =====================
if (permissions && permissions.length > 0) {
const hasPermission = permissions.every((perm) => userPermissions.includes(perm))
if (!hasPermission) {
return (
<Result
status="403"
title="403"
subTitle="抱歉,您没有访问此页面的权限。"
extra={
<Button type="primary" onClick={() => window.history.back()}>
返回上一页
</Button>
}
/>
)
}
}
return <>{children}</>
}
export default ProtectedRoute
路由配置:
// src/router/index.tsx
import { createBrowserRouter, Navigate } from 'react-router-dom'
import ProtectedRoute from '@/components/ProtectedRoute'
import AdminLayout from '@/layouts/AdminLayout'
import Login from '@/views/login/LoginView'
import Dashboard from '@/views/dashboard/DashboardView'
import UserManagement from '@/views/system/UserManagement'
import Settings from '@/views/system/Settings'
export const routerConfig = createBrowserRouter([
{
path: '/login',
element: <Login />,
},
{
path: '/',
element: <AdminLayout />,
children: [
{
index: true,
element: <Navigate to="/dashboard" replace />,
},
{
path: 'dashboard',
element: (
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
),
},
{
path: 'users',
element: (
<ProtectedRoute roles={['admin']} permissions={['system:user:list']}>
<UserManagement />
</ProtectedRoute>
),
},
{
path: 'settings',
element: (
<ProtectedRoute roles={['admin']}>
<Settings />
</ProtectedRoute>
),
},
],
},
{
path: '/403',
element: (
<Result status="403" title="403" subTitle="无权限访问此页面。" />
),
},
{
path: '*',
element: (
<Result status="404" title="404" subTitle="页面不存在。" />
),
},
])
5.2 全局路由守卫(类似 beforeEach)
React Router v6 没有 vue-router 那样的导航守卫,但可以通过路由拦截组件 + layout 组件内的监听实现:
// src/components/AppRouter.tsx
import { useEffect } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useUserStore } from '@/stores/userStore'
import { message } from 'antd'
/**
* 全局路由守卫组件(作为 Layout 的根节点)
*
* 处理:
* - 页面标题
* - 登录态校验(已在每个 ProtectedRoute 中处理,这里处理非 ProtectedRoute 的情况)
* - 页面访问埋点
*/
const AppRouter: React.FC = () => {
const location = useLocation()
const navigate = useNavigate()
useEffect(() => {
const { token } = useUserStore.getState()
// 设置页面标题
document.title = `${location.pathname} - Admin`
// 页面访问埋点
console.log(`[Page View] ${location.pathname}`, {
timestamp: Date.now(),
token: !!token,
})
}, [location])
return <Outlet />
}
export default AppRouter
5.3 动态路由实现
// src/router/dynamic-routes.tsx
import { lazy, Suspense } from 'react'
import type { RouteObject } from 'react-router-dom'
import { Spin } from 'antd'
// 组件路径映射表(约定优于配置)
const componentMap: Record<string, React.LazyExoticComponent<React.ComponentType<any>>> = {
'system/user': lazy(() => import('@/views/system/UserManagement')),
'system/role': lazy(() => import('@/views/system/RoleManagement')),
'system/menu': lazy(() => import('@/views/system/MenuManagement')),
'content/article': lazy(() => import('@/views/content/ArticleList')),
}
/**
* 根据后端返回的菜单配置生成动态路由
*/
export function generateDynamicRoutes(menus: ApiMenu[]): RouteObject[] {
return menus
.filter((menu) => menu.visible !== false)
.map((menu) => {
const Component = menu.component ? componentMap[menu.component] : undefined
const route: RouteObject = {
path: menu.path,
// lazy load component
element: Component ? (
<Suspense fallback={<Spin style={{ display: 'flex', justifyContent: 'center', marginTop: 200 }} />}>
<Component />
</Suspense>
) : undefined,
children: menu.children ? generateDynamicRoutes(menu.children) : undefined,
}
return route
})
}
// 在 App 或 Layout 初始化时调用
// src/App.tsx
import { useEffect } from 'react'
import { RouterProvider } from 'react-router-dom'
import { routerConfig } from '@/router'
import { useUserStore } from '@/stores/userStore'
import { generateDynamicRoutes } from '@/router/dynamic-routes'
function App() {
const { token, dynamicRoutes, setDynamicRoutes } = useUserStore()
useEffect(() => {
if (token && dynamicRoutes.length === 0) {
fetchMenus().then((menus) => {
const routes = generateDynamicRoutes(menus)
setDynamicRoutes(routes)
})
}
}, [token])
return <RouterProvider router={routerConfig} />
}
六、权限指令与权限组件
6.1 Vue 自定义指令 v-permission(按钮级权限)
// src/directives/permission.ts
import type { App, DirectiveBinding } from 'vue'
import { useUserStore } from '@/stores/user'
/**
* 权限指令
*
* 用法:
* <el-button v-permission="'system:user:add'">新增用户</el-button>
* <el-button v-permission="['system:user:add', 'system:user:edit']">操作</el-button>
* <el-button v-permission:or="['system:user:add', 'system:user:edit']">操作</el-button>
*/
function checkPermission(el: HTMLElement, binding: DirectiveBinding) {
const userStore = useUserStore()
// 防御式编程:用户信息未加载完直接返回
if (!userStore.permissions || userStore.permissions.length === 0) {
el.parentNode?.removeChild(el)
return
}
const value = binding.value
const arg = binding.arg // 'or' 或 undefined
// ❌ 未传入权限码 → 显示元素
if (!value) return
const requiredPermissions: string[] = Array.isArray(value) ? value : [value]
// 默认 AND 逻辑:必须拥有所有权限码
let hasPermission: boolean
if (arg === 'or') {
// OR 逻辑:拥有任一权限码即可
hasPermission = requiredPermissions.some((perm) =>
userStore.permissions.includes(perm),
)
} else {
// AND 逻辑:必须拥有所有权限码(默认)
hasPermission = requiredPermissions.every((perm) =>
userStore.permissions.includes(perm),
)
}
if (!hasPermission) {
// ❌ 无权限 → 从 DOM 中移除元素
el.parentNode?.removeChild(el)
}
}
/**
* v-permission 更新处理
* ⚠️ 当权限列表动态变化时需要重新校验
*/
const updatedDirective = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
checkPermission(el, binding)
},
updated(el: HTMLElement, binding: DirectiveBinding) {
// 权限发生变化时重新校验
if (binding.value !== binding.oldValue) {
checkPermission(el, binding)
}
},
}
export function setupPermissionDirective(app: App) {
app.directive('permission', updatedDirective)
}
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { setupPermissionDirective } from '@/directives/permission'
const app = createApp(App)
setupPermissionDirective(app)
app.mount('#app')
使用示例:
<template>
<div class="user-management">
<div class="toolbar">
<!-- ❌ 无权限 → 按钮不渲染 -->
<el-button v-permission="'system:user:add'" type="primary" @click="handleAdd">
新增用户
</el-button>
<!-- 拥有任一权限即可显示 -->
<el-button v-permission:or="['system:user:export', 'system:user:import']" @click="handleBatch">
批量操作
</el-button>
<!-- 必须有所有权限才显示 -->
<el-button v-permission="['system:user:edit', 'system:user:delete']" @click="handleManage">
管理
</el-button>
</div>
<el-table :data="users">
<el-table-column prop="name" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<!-- 按钮级权限:行内操作 -->
<el-table-column label="操作">
<template #default="{ row }">
<el-button
v-permission="'system:user:edit'"
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-permission="'system:user:delete'"
type="danger"
link
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
6.2 React 权限组件 <Permission>
// src/components/Permission.tsx
import { useUserStore } from '@/stores/userStore'
interface PermissionProps {
children: React.ReactNode
/** 所需的权限码(数组表示 AND 逻辑) */
permissions: string[]
/** 逻辑模式:and(默认,拥有所有权限)| or(拥有任一即可) */
mode?: 'and' | 'or'
/** 无权限时的 fallback 内容(默认不渲染) */
fallback?: React.ReactNode
}
/**
* 权限组件
*
* 用法:
* <Permission permissions="system:user:add">
* <Button type="primary">新增用户</Button>
* </Permission>
*
* <Permission permissions={['system:user:add', 'system:user:edit']} mode="or">
* <Button>操作</Button>
* </Permission>
*/
const Permission: React.FC<PermissionProps> = ({
children,
permissions,
mode = 'and',
fallback = null,
}) => {
const userPermissions = useUserStore((state) => state.permissions)
// 防御式编程:权限数据未加载 → 不渲染
if (!userPermissions || userPermissions.length === 0) {
return <>{fallback}</>
}
const requiredPerms = Array.isArray(permissions) ? permissions : [permissions]
if (requiredPerms.length === 0) return <>{children}</>
let hasPermission: boolean
if (mode === 'or') {
hasPermission = requiredPerms.some((perm) => userPermissions.includes(perm))
} else {
hasPermission = requiredPerms.every((perm) => userPermissions.includes(perm))
}
return hasPermission ? <>{children}</> : <>{fallback}</>
}
export default Permission
// src/components/Can.tsx (另一种更语义化的命名)
/**
* 简洁的权限守卫组件
*
* <Can perm="system:user:add">
* <Button>新增</Button>
* </Can>
*/
export const Can: React.FC<{
perm: string | string[]
mode?: 'and' | 'or'
children: React.ReactNode
}> = ({ perm, mode, children }) => (
<Permission permissions={Array.isArray(perm) ? perm : [perm]} mode={mode}>
{children}
</Permission>
)
使用示例:
import { Button, Table, Space, message } from 'antd'
import Permission from '@/components/Permission'
const UserManagement: React.FC = () => {
const columns = [
{ title: '用户名', dataIndex: 'name', key: 'name' },
{ title: '邮箱', dataIndex: 'email', key: 'email' },
{
title: '操作',
key: 'action',
render: (_: unknown, record: User) => (
<Space>
{/* 权限不够,整个按钮不显示 */}
<Permission permissions="system:user:edit">
<Button type="link" onClick={() => handleEdit(record)}>
编辑
</Button>
</Permission>
{/* 显示禁用状态而不是直接隐藏 */}
<Permission
permissions="system:user:delete"
fallback={
<Button type="link" disabled title="没有删除权限">
删除
</Button>
}
>
<Button type="link" danger onClick={() => handleDelete(record)}>
删除
</Button>
</Permission>
</Space>
),
},
]
return (
<div>
{/* ❌ 无权限 → Button 不渲染 */}
<Permission permissions="system:user:add">
<Button type="primary" onClick={handleAdd}>
新增用户
</Button>
</Permission>
{/* 拥有任一权限即可显示 */}
<Permission permissions={['system:user:export', 'system:user:import']} mode="or">
<Button onClick={handleBatch}>批量操作</Button>
</Permission>
<Table columns={columns} dataSource={users} />
</div>
)
}
七、RBAC 在前端的落地
7.1 RBAC 模型在前端的映射
后端 RBAC 模型:
用户 (User)
│ 多对多
角色 (Role) ——— 多对多 ——— 权限 (Permission)
│
菜单 (Menu) / 按钮 (Button) ═══ 关联权限码
前端对应数据结构:
{
userInfo: { id, name, avatar },
roles: ['admin', 'editor'], // 角色列表
permissions: ['system:user:list', 'system:user:add', ...], // 权限码列表
menus: [ // 菜单配置
{ path: '/dashboard', title: '仪表盘', icon: 'Dashboard' },
{ path: '/system', title: '系统管理', children: [
{ path: '/system/user', title: '用户管理', permission: 'system:user:list' },
{ path: '/system/role', title: '角色管理', permission: 'system:role:list' },
]},
],
}
7.2 登录后获取角色和权限列表
Vue 3 + Pinia:
// src/stores/user.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { loginApi, getUserInfoApi, getMenusApi } from '@/api/user'
import router from '@/router'
import { generateDynamicRoutes } from '@/router/dynamic-routes'
import type { ApiMenu } from '@/types/menu'
interface LoginParams {
username: string
password: string
}
interface UserInfo {
id: number
username: string
avatar: string
email: string
}
export const useUserStore = defineStore('user', () => {
// ===================== State =====================
const token = ref(localStorage.getItem('accessToken') ?? '')
const refreshToken = ref(localStorage.getItem('refreshToken') ?? '')
const userInfo = ref<UserInfo | null>(null)
const roles = ref<string[]>([])
const permissions = ref<string[]>([])
const menus = ref<ApiMenu[]>([])
const dynamicRoutesLoaded = ref(false)
// ===================== Actions =====================
/** 登录 */
async function login(params: LoginParams) {
const res = await loginApi(params)
const { accessToken: at, refreshToken: rt } = res.data
// ✅ 持久化存储
token.value = at
refreshToken.value = rt
localStorage.setItem('accessToken', at)
localStorage.setItem('refreshToken', rt)
}
/** 获取用户信息(登录后或刷新页面后调用) */
async function fetchUserInfo() {
const res = await getUserInfoApi()
const data = res.data
userInfo.value = {
id: data.id,
username: data.username,
avatar: data.avatar,
email: data.email,
}
roles.value = data.roles ?? []
permissions.value = data.permissions ?? []
}
/** 获取用户菜单并注册动态路由 */
async function fetchUserMenus() {
const res = await getMenusApi()
menus.value = res.data
// 生成动态路由并注册
const dynamicRoutes = generateDynamicRoutes(menus.value)
dynamicRoutes.forEach((route) => {
if (!router.hasRoute(route.name as string)) {
router.addRoute(route)
}
})
dynamicRoutesLoaded.value = true
}
/** 退出登录 */
function logout() {
token.value = ''
refreshToken.value = ''
userInfo.value = null
roles.value = []
permissions.value = []
menus.value = []
dynamicRoutesLoaded.value = false
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
router.push('/login')
}
return {
token,
refreshToken,
userInfo,
roles,
permissions,
menus,
dynamicRoutesLoaded,
login,
fetchUserInfo,
fetchUserMenus,
logout,
}
})
React + Zustand:
// src/stores/userStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { loginApi, getUserInfoApi, getMenusApi } from '@/api/user'
interface UserInfo {
id: number
username: string
avatar: string
email: string
}
interface ApiMenu {
path: string
title: string
icon?: string
component?: string
permission?: string
visible?: boolean
children?: ApiMenu[]
}
interface UserState {
// State
token: string
refreshToken: string
userInfo: UserInfo | null
roles: string[]
permissions: string[]
menus: ApiMenu[]
dynamicRoutesLoaded: boolean
// Actions
login: (params: { username: string; password: string }) => Promise<void>
fetchUserInfo: () => Promise<void>
fetchUserMenus: () => Promise<ApiMenu[]>
setDynamicRoutes: (routes: any[]) => void
logout: () => void
}
export const useUserStore = create<UserState>()(
persist(
(set, get) => ({
// State
token: '',
refreshToken: '',
userInfo: null,
roles: [],
permissions: [],
menus: [],
dynamicRoutesLoaded: false,
// Actions
login: async (params) => {
const res = await loginApi(params)
const { accessToken, refreshToken } = res.data
set({
token: accessToken,
refreshToken,
})
},
fetchUserInfo: async () => {
const res = await getUserInfoApi()
const data = res.data
set({
userInfo: {
id: data.id,
username: data.username,
avatar: data.avatar,
email: data.email,
},
roles: data.roles ?? [],
permissions: data.permissions ?? [],
})
},
fetchUserMenus: async () => {
const res = await getMenusApi()
const menus = res.data
set({ menus, dynamicRoutesLoaded: true })
return menus
},
setDynamicRoutes: (routes) => {
// 动态路由存储在另一个 store 或全局变量中
},
logout: () => {
set({
token: '',
refreshToken: '',
userInfo: null,
roles: [],
permissions: [],
menus: [],
dynamicRoutesLoaded: false,
})
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
},
}),
{
name: 'user-storage',
// 只持久化 token,用户信息每次刷新重新获取
partialize: (state) => ({
token: state.token,
refreshToken: state.refreshToken,
}),
},
),
)
7.3 动态生成菜单(侧边栏)
Vue 3 + Element Plus:
<!-- src/layouts/components/Sidebar.vue -->
<template>
<el-menu
:default-active="route.path"
:collapse="isCollapse"
router
>
<template v-for="menu in filteredMenus" :key="menu.path">
<!-- 有子菜单 -->
<el-sub-menu v-if="menu.children?.length" :index="menu.path">
<template #title>
<el-icon><component :is="menu.icon" /></el-icon>
<span>{{ menu.title }}</span>
</template>
<el-menu-item
v-for="child in menu.children"
:key="child.path"
:index="child.path"
>
<span>{{ child.title }}</span>
</el-menu-item>
</el-sub-menu>
<!-- 没有子菜单 -->
<el-menu-item v-else :index="menu.path">
<el-icon><component :is="menu.icon" /></el-icon>
<span>{{ menu.title }}</span>
</el-menu-item>
</template>
</el-menu>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
defineProps<{ isCollapse: boolean }>()
const route = useRoute()
const userStore = useUserStore()
/** 过滤无权限的菜单 */
const filteredMenus = computed(() => {
return filterMenuByPermission(userStore.menus)
})
/** 递归过滤菜单:没有权限的子菜单整体移除 */
function filterMenuByPermission(menus: any[]): any[] {
return menus
.filter((menu) => {
// 如果菜单需要权限码,检查是否拥有
if (menu.permission) {
return userStore.permissions.includes(menu.permission)
}
return true
})
.map((menu) => {
if (menu.children) {
return {
...menu,
children: filterMenuByPermission(menu.children),
}
}
return menu
})
.filter((menu) => {
// 父菜单没有可显示的子孙 → 移除
if (menu.children && menu.children.length === 0) return false
return true
})
}
</script>
React + Ant Design:
// src/layouts/Sidebar.tsx
import { useNavigate, useLocation } from 'react-router-dom'
import { Menu } from 'antd'
import type { MenuProps } from 'antd'
import { useUserStore } from '@/stores/userStore'
import {
DashboardOutlined,
UserOutlined,
SettingOutlined,
} from '@ant-design/icons'
/** 图标映射表 */
const iconMap: Record<string, React.ReactNode> = {
Dashboard: <DashboardOutlined />,
User: <UserOutlined />,
Setting: <SettingOutlined />,
}
const Sidebar: React.FC = () => {
const navigate = useNavigate()
const location = useLocation()
const { menus, permissions } = useUserStore()
/** 递归过滤菜单 */
const filterMenu = (items: ApiMenu[]): MenuProps['items'] => {
return items
.filter((item) => {
if (item.permission) return permissions.includes(item.permission)
return true
})
.map((item) => {
const menuItem: any = {
key: item.path,
icon: item.icon ? iconMap[item.icon] : undefined,
label: item.title,
}
if (item.children) {
const filteredChildren = filterMenu(item.children)
if (filteredChildren && filteredChildren.length > 0) {
menuItem.children = filteredChildren
}
}
return menuItem
})
.filter(Boolean)
}
const menuItems = filterMenu(menus)
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
navigate(key)
}
return (
<Menu
mode="inline"
selectedKeys={[location.pathname]}
defaultOpenKeys={menus.map((m) => m.path)}
items={menuItems}
onClick={handleMenuClick}
/>
)
}
export default Sidebar
7.4 权限数据流总览
登录流程:
┌──────────┐
│ 用户登录 │
└────┬─────┘
↓
┌──────────────┐
│ 获取 Token │ ← 后端验证用户名密码
└────┬─────────┘
↓
┌──────────────────┐
│ 存储 Token │ ← localStorage / sessionStorage
│ 获取用户信息 │ ← /api/user/info(角色、权限码)
│ 获取菜单配置 │ ← /api/user/menus
└────┬─────────────┘
↓
┌────────────────────┐
│ 动态注册路由 │ ← addRoute
│ 生成侧边栏菜单 │ ← 权限过滤
│ 初始化按钮级权限 │ ← v-permission / <Permission>
└────┬───────────────┘
↓
┌──────────┐
│ 进入首页 │
└──────────┘
页面刷新流程:
┌──────────────┐
│ 页面刷新 │
└────┬─────────┘
↓
┌──────────────────┐
│ 从 storage 读取 │
│ Token 是否存在? │
└────┬─────────────┘
│
┌────┴────┐ ┌──────────────┐
│ 存在 │───有────→│ 获取用户信息 │
└────┬────┘ │ 获取菜单配置 │
│ └──────┬───────┘
│ 不存在 ↓
↓ ┌──────────────┐
┌──────────┐ │ 动态注册路由 │
│ 跳转登录 │ │ 渲染页面 │
└──────────┘ └──────────────┘
八、退出登录处理
8.1 标准退出流程
用户点击"退出登录"
↓
1. 调用后端退出接口(让后端清除 Session/Token 缓存)
2. 清除本地 Token(localStorage / sessionStorage)
3. 清除用户状态(Pinia / Zustand store)
4. 清除动态路由(重置路由到初始状态)
5. 跳转登录页
8.2 Vue 退出登录实现
// src/stores/user.ts - logout action
async function logout() {
try {
// 1. 通知后端销毁 Token(可选,JWT 无状态时可以跳过)
await logoutApi()
} catch {
// 即使后端接口失败,前端也要清理状态
console.warn('退出登录接口调用失败,继续执行本地清理')
} finally {
// 2. 清除本地 Token
token.value = ''
refreshToken.value = ''
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
// 3. 清除用户状态
userInfo.value = null
roles.value = []
permissions.value = []
menus.value = []
// 4. 清除动态路由(回到初始路由配置)
dynamicRoutesLoaded.value = false
const dynamicRouteNames = router.getRoutes()
.filter((r) => r.meta?.dynamic)
.map((r) => r.name as string)
dynamicRouteNames.forEach((name) => {
if (router.hasRoute(name)) {
router.removeRoute(name)
}
})
// 5. 跳转登录页
router.push('/login')
}
}
8.3 React 退出登录实现
// src/stores/userStore.ts
import { create } from 'zustand'
import { logoutApi } from '@/api/user'
// ...在 store 中
logout: async () => {
try {
await logoutApi()
} catch {
console.warn('退出登录接口调用失败,继续执行本地清理')
} finally {
set({
token: '',
refreshToken: '',
userInfo: null,
roles: [],
permissions: [],
menus: [],
dynamicRoutesLoaded: false,
})
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
// 跳转登录页
window.location.href = '/login'
// 或使用 navigate:注意不能在 store 中使用 hooks
}
},
8.4 多标签页同步退出(BroadcastChannel)
当用户在一个标签页退出登录时,其他标签页也应该同步退出。
// src/utils/auth-sync.ts
/**
* 多标签页登录状态同步
*
* 原理:利用 BroadcastChannel API 在同源的不同标签页间通信
* 兼容性:Chrome 54+, Firefox 38+, Safari 15.4+
*/
const CHANNEL_NAME = 'auth-sync'
const LOGOUT_EVENT = 'logout'
let channel: BroadcastChannel | null = null
/**
* 初始化广播频道监听
* 在应用入口处调用一次
*/
export function initAuthSync(onLogout: () => void) {
try {
channel = new BroadcastChannel(CHANNEL_NAME)
channel.onmessage = (event) => {
if (event.data === LOGOUT_EVENT) {
console.log('[AuthSync] 收到退出事件,同步退出')
onLogout()
}
}
console.log('[AuthSync] 多标签页同步已初始化')
} catch (error) {
// BroadcastChannel 不可用时静默降级
console.warn('[AuthSync] BroadcastChannel 不可用,降级为单标签页模式', error)
}
}
/**
* 广播退出事件
*/
export function broadcastLogout() {
if (channel) {
channel.postMessage(LOGOUT_EVENT)
}
}
/**
* 清理广播频道
*/
export function destroyAuthSync() {
if (channel) {
channel.close()
channel = null
}
}
Vue 中使用:
// src/App.vue
import { onMounted, onUnmounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { initAuthSync, destroyAuthSync } from '@/utils/auth-sync'
const userStore = useUserStore()
onMounted(() => {
initAuthSync(() => {
userStore.logout()
// 提示用户
ElMessage.warning('您的账号已在其他设备退出登录')
})
})
onUnmounted(() => {
destroyAuthSync()
})
React 中使用:
// src/App.tsx
import { useEffect } from 'react'
import { useUserStore } from '@/stores/userStore'
import { initAuthSync, destroyAuthSync } from '@/utils/auth-sync'
import { message } from 'antd'
function App() {
const logout = useUserStore((state) => state.logout)
useEffect(() => {
initAuthSync(() => {
logout()
message.warning('您的账号已在其他设备退出登录')
})
return () => {
destroyAuthSync()
}
}, [logout])
return (
// ...
)
}
多标签页同步流程:
标签页 A 标签页 B
│ │
├── 用户点击退出登录 │
│ │
├── 1. 调用后端退出接口 │
├── 2. 清理本地状态 │
├── 3. 广播退出事件 ────────────→│
│ BroadcastChannel │
│ ├── 收到退出事件
│ ├── 1. 清理本地状态
│ ├── 2. 跳转登录页
│ ├── 3. 显示提示 Toast
│ │
├── 4. 跳转登录页 │
│ │
8.5 退出登录常见问题
| 问题 | 处理方式 |
|---|---|
| 退出接口失败 | 前端依然执行本地清理,保证用户能正常登出 |
| Token 已过期 | 退出时忽略后端接口调用,直接清理本地状态 |
| 多标签页不同步 | 使用 BroadcastChannel 或 storage 事件监听 |
| 不清除动态路由 | 退出后路由表中残留权限路由,下次登录可能出现 404 |
| 不清除权限数据 | 下次登录权限判断错误,页面显示异常 |
九、常见问题与踩坑点
9.1 刷新页面后路由守卫失效
现象:刷新页面后,有权限的用户被跳转到 403 或登录页。
原因:刷新时 Pinia/Zustand 状态丢失,用户信息和权限列表需要重新获取,但路由守卫在数据加载完成前已执行。
解决方案:
// ✅ 正确做法:在路由守卫中使用 async/await 等待数据加载
router.beforeEach(async (to, _from, next) => {
const userStore = useUserStore()
// 有 Token 但用户信息未加载 → 先拉取
if (userStore.token && !userStore.userInfo) {
try {
await userStore.fetchUserInfo() // 等待用户信息加载
await userStore.fetchUserMenus() // 等待菜单加载
} catch {
userStore.logout()
next({ path: '/login' })
return
}
}
next()
})
// ✅ React: 使用 loader 或外层组件
// src/layouts/AdminLayout.tsx
const AdminLayout: React.FC = () => {
const { token, userInfo, fetchUserInfo, fetchUserMenus } = useUserStore()
const [loading, setLoading] = useState(true)
useEffect(() => {
if (token && !userInfo) {
setLoading(true)
Promise.all([fetchUserInfo(), fetchUserMenus()])
.catch(() => {
useUserStore.getState().logout()
})
.finally(() => setLoading(false))
} else {
setLoading(false)
}
}, [token, userInfo])
if (loading) {
return <Spin fullscreen />
}
return (
<Layout>
<Sidebar />
<Content><Outlet /></Content>
</Layout>
)
}
9.2 Token 并发刷新问题
现象:多个请求同时返回 401,各自触发 refreshToken,导致多次刷新请求。
解决方案:使用"请求队列"机制(见第三章 axios 拦截器部分),同一时间只允许一个刷新请求,其他请求排队等待。
9.3 动态路由 404 问题
现象:动态路由添加后访问仍显示 404。
原因:
- Vue Router 的
addRoute不会影响当前导航,需要重定向 - 路由添加顺序问题:嵌套路由必须先添加父路由
排查步骤:
// ✅ 调试:打印当前所有注册路由
console.log(
router.getRoutes().map((r) => ({
name: r.name,
path: r.path,
meta: r.meta,
})),
)
// ✅ 添加路由后强制刷新匹配
router.replace(to.fullPath)
9.4 权限码命名规范
| 场景 | 命名格式 | 示例 |
|---|---|---|
| 模块 | 模块名:* |
system:* |
| 列表/查询 | 模块名:list |
system:user:list |
| 新增 | 模块名:add |
system:user:add |
| 编辑 | 模块名:edit |
system:user:edit |
| 删除 | 模块名:delete |
system:user:delete |
| 导出 | 模块名:export |
system:user:export |
| 导入 | 模块名:import |
system:user:import |
最佳实践:前后端约定统一权限码命名规范,建议使用 资源:操作 格式。
9.5 前后端权限校验对比
| 维度 | 前端 | 后端 |
|---|---|---|
| 校验时机 | 路由跳转前、组件渲染时 | 每个 API 请求 |
| 校验对象 | URL、组件、按钮 | URL、Method、数据行 |
| 校验依据 | localStorage 中的权限列表 | 数据库/缓存中的角色权限 |
| 可靠性 | ⭐(可被绕过) | ⭐⭐⭐⭐⭐ |
| 目的 | 提升用户体验 | 保障系统安全 |
9.6 安全建议总结
| 建议 | 说明 |
|---|---|
| 永远不要信任前端 | 所有权限校验在后端重复执行 |
| Token 不要存敏感数据 | JWT payload 可被任何人解码查看 |
| 使用 HTTPS | 防止 Token 被中间人攻击截获 |
| Access Token 短时效 | 建议 15-30 分钟,减少泄露风险 |
| 敏感操作二次验证 | 修改密码、转账等需额外确认 |
| CSP 防 XSS | Content-Security-Policy 减少脚本注入 |
| 退出清所有状态 | 清除 Token、用户信息、动态路由 |
十、要点速记
10.1 核心原则
前端鉴权 = 用户体验层防护
后端鉴权 = 系统安全层兜底
永远不要信任客户端数据
最小权限原则:只给当前页面需要的权限
10.2 关键流程口诀
登录拿到双 Token
请求拦截自动带
401 来了先刷新
刷新失败跳登录
路由守卫 beforeEach
无 Token 跳登录页
有 Token 查角色
角色不对走 403
退出登录三件事
清除 Token 清状态
跳转登录页收工
多标签页广播通
10.3 技术栈对应表
| 功能 | Vue 技术选型 | React 技术选型 |
|---|---|---|
| 状态管理 | Pinia | Zustand / Redux Toolkit |
| 路由 | Vue Router 4 | React Router v6 |
| UI 组件 | Element Plus | Ant Design |
| HTTP 请求 | axios + 拦截器 | axios + 拦截器 |
| 权限指令 | v-permission 自定义指令 |
<Permission> 组件 |
| 登录态同步 | BroadcastChannel | BroadcastChannel |
| Token 存储 | localStorage | localStorage |
10.4 文件目录建议
src/
├── api/
│ ├── user.ts # 用户登录、信息、菜单 API
│ └── auth.ts # 刷新 Token 等认证相关 API
├── stores/
│ ├── user.ts (Vue/Pinia) # 用户状态管理
│ └── userStore.ts (React) # 用户状态管理
├── router/
│ ├── index.ts # 路由配置
│ ├── guards.ts # 路由守卫(Vue)
│ └── dynamic-routes.ts # 动态路由生成
├── directives/
│ └── permission.ts # v-permission 自定义指令(Vue)
├── components/
│ ├── ProtectedRoute.tsx # 路由守卫组件(React)
│ └── Permission.tsx # 权限组件(React)
├── layouts/
│ ├── Sidebar.vue # 侧边栏菜单(Vue)
│ └── Sidebar.tsx # 侧边栏菜单(React)
├── utils/
│ ├── http.ts # axios 封装 + 拦截器
│ ├── jwt.ts # JWT 解析工具
│ └── auth-sync.ts # 多标签页同步
└── views/
├── login/
└── error/
├── Forbidden.vue/tsx
└── NotFound.vue/tsx