前端人保命指南:3招搞定路由权限拦截,让未登录用户彻底没门
前端人保命指南:3招搞定路由权限拦截,让未登录用户彻底没门
前端人保命指南:3招搞定路由权限拦截,让未登录用户彻底没门
先别急着写代码,聊聊咱们踩过的那些"裸奔"坑
说真的,每次看到有人问我"前端路由权限怎么做"的时候,我脑子里第一个浮现的画面,就是三年前那个让我至今想起来都脚趾抠地的下午。
那会儿我刚毕业,进了一家做后台管理系统的公司。带我的老哥跟我说:"你把页面搭起来就行,权限后端会做。"我信了,我真的信了。于是我把所有后台接口地址直接写死在代码里,路由配置得明明白白,哪个页面对应哪个路径,整整齐齐。当时我还觉得自己挺专业的,毕竟代码写得工整啊。
结果呢?测试小姐姐拿着一张截图直接甩到我工位上。截图里,她把浏览器地址栏里的/api/admin/user/list改成了/api/admin/finance/report,回车一按,财务数据出来了。她没登录,她甚至都不是我们公司的人,她就是随手一试。
那一刻我感觉天都塌了。截图里她配的文字是:"这就是你们系统的安全性?"我至今记得那个红色感叹号,刺眼得像是在嘲笑我的发际线。
后来我才明白,所谓的"后端做权限"只是校验你有没有资格调这个接口,但前端要是直接把敏感页面的链接暴露出去,用户虽然拿不到数据( hopefully ),但那个"您无权访问"的报错页面弹出来,体验已经崩了。更惨的是有些系统,前端页面都渲染出来了,只是数据区域显示个"加载失败",这不就是告诉黑客"这里有东西,只是你没拿到钥匙"吗?
还有个更离谱的,是我第二家公司遇到的。他们做了个"退出登录"功能,点完按钮跳转到登录页,看起来一切正常。但是!你点一下浏览器的后退键,奇迹发生了——刚才那个需要登录才能看的仪表盘,又完整地出现了!页面上的数据甚至还保留着!我当时就懵了,这退出登了个寂寞?
后来扒代码才发现,他们用的是Vue Router的hash模式,退出登录只是清空了Vuex里的userInfo,但浏览器的历史记录里,那个路由状态还在。用户点后退,Vue Router从history里恢复状态,组件重新渲染,数据虽然是从接口拿的(这时候Token已经被删了,按理说拿不到数据),但页面骨架已经暴露了,而且有些缓存机制还会让部分数据残留。这逻辑脆弱得跟我日渐稀疏的头顶一样,风一吹就露馅。
所以说啊,光靠后端校验真的不够。你想啊,用户点了个需要权限的页面,前端不管三七二十一先渲染,然后调接口,接口返回401,再弹个框提示"请先登录",这中间的Loading转圈圈能转两秒。两秒听起来不长?错,在互联网时代,两秒足够让用户觉得"这什么破系统,卡死了"。而且万一网络抖动一下,用户看到的是半个页面配一堆报错,这体验简直灾难。
咱们今天不整那些什么"RBAC模型"、"零信任架构"之类听起来很高大上的名词。我就讲最土的办法,怎么把路由大门焊死,让不该进来的人连门把手都摸不着。目标很简单:未登录用户,管你输入什么地址,老老实实给我去登录页,别整那些花里胡哨的。
扒一扒路由保护的底裤,到底是谁在把关
要搞定路由拦截,得先搞清楚家里的保安系统是怎么运作的。不然你对着空气喊"拦住他",保安大爷还在岗亭里打瞌睡呢。
想象一下,你的前端应用就是个小区。Router(路由器)就是那个小区的大门,所有的车辆(URL请求)都要从这里进出。但是大门只管放行,不管查身份。这时候就需要Guard(守卫)——也就是那个坐在门岗亭里、拿着业主名单的大爷。大爷的工作很简单:每辆车进来的时候,摇下车窗,出示证件,对不上名单?不好意思,请回。
在前端路由的世界里,这个"大爷"其实分三种,官大官小不一样,管的事儿也不同。
全局前置守卫(Global Before Guards)是老大,官最大,管得最宽。它在整个应用层面监听所有路由变化,每次URL要变的时候,它第一个跳出来拦路检查。Vue Router里叫router.beforeEach,React Router v6里虽然没有直接等价物,但你可以用useEffect配合location变化来模拟,或者用第三方库。这哥们儿适合干脏活累活,比如检查用户有没有登录Token,没有的话统一踢到登录页。
独享守卫(Per-Route Guard)是老二,只管自己门前那一亩三分地。Vue Router里是在路由配置里写beforeEnter,React Router可以在具体路由元素上包装。比如你的/admin路由,专门配个守卫检查"你是不是管理员",普通用户连这个路由的组件都不会看到,直接拦截在门外。
组件内守卫(In-Component Guards)是老三,级别最低,但最灵活。它在组件内部,Vue里是beforeRouteEnter、beforeRouteUpdate这些生命周期钩子,React里其实就是组件里的useEffect。这货适合做一些"进了门之后还要再检查"的事儿,比如用户已经登录了,也能进这个页面,但页面里有个"删除"按钮,得再查查他有没有删除权限。
这三兄弟的执行顺序是固定的:全局前置 → 独享 → 组件内。记住这个顺序很重要,不然你会遇到那种"全局守卫说可以进,独享守卫说不行"的混乱场面,到时候调试起来头都要秃。
再说说那个"通行证"——Token。现在主流的无状态认证都是JWT(JSON Web Token),用户登录成功后,后端发个字符串过来,里面包含了用户ID、角色、过期时间这些信息。这个字符串存在哪儿呢?有三个地方可选,每个都有坑。
LocalStorage最方便,存进去之后所有请求都能拿到,页面关了再开还在。但问题是,它怕XSS攻击(跨站脚本攻击)。如果哪个缺德的在你页面里注入了恶意脚本,它能直接localStorage.getItem('token')把你的令牌偷走。而且用户清浏览器缓存的时候,LocalStorage一锅端,Token没了,用户得重新登录,体验不好。
Cookie相对安全一点,可以设HttpOnly属性,这样前端JS都读不到,自然也不怕XSS偷了。但Cookie有大小限制(一般4KB),而且每次请求都会自动带上,如果Token太大,会浪费带宽。还有CSRF(跨站请求伪造)的风险,虽然现代浏览器有SameSite属性可以缓解,但老浏览器不支持。
SessionStorage跟LocalStorage类似,但页面一关就清,适合那种"临时登录"的场景。不过大部分后台系统还是希望用户下次打开浏览器不用重新登录,所以用得少。
我一般推荐的做法是:Access Token放内存(比如React Context或Vuex里),Refresh Token放HttpOnly Cookie。但小项目别搞这么复杂,LocalStorage存个Token,配合路由守卫做校验,够用了。记住,前端的存储都是"防君子不防小人",真要安全还得靠后端。
最后提一嘴角色(Role)和权限(Permission)的区别,这俩经常搞混。Role是"身份",比如"管理员"、“普通用户”、“财务专员”。Permission是"能力",比如"查看订单"、“删除用户”、“导出报表”。一个Role可以有一堆Permission,一个Permission也可以分配给多个Role。路由级别的一般按Role拦,比如"只有管理员能进后台管理页"。按钮级别的一般按Permission拦,比如"这个删除按钮,只有拥有user:delete权限的人才看得见"。别把钥匙搞混了,不然会出现"普通用户进了管理页,但看不到任何操作按钮"的尴尬局面——虽然安全,但体验诡异。
手把手教你三套方案,总有一款适合你的烂摊子
好了,理论说完,上硬菜。我准备了三套方案,从简单粗暴到花里胡哨,你根据自己项目的复杂度挑。
方案一:全局拦截大法——一把锁管所有门
这是我最推荐的,适合90%的项目。思路很简单:在路由跳转之前,统一检查登录状态,没登录?滚去登录页。
Vue Router 3.x 实现:
// router/index.jsimport Vue from'vue'import Router from'vue-router'import routes from'./routes'import{ getToken }from'@/utils/auth'// 你自己封装的获取Token方法 Vue.use(Router)const router =newRouter({mode:'history',routes: routes })// 全局前置守卫,这就是咱们的看门大爷 router.beforeEach((to, from, next)=>{// 先拿到Token,看看用户有没有"通行证"const hasToken =getToken()// 如果用户要去的就是登录页,那还检查个啥,直接放行// 不然会出现"在登录页检查没登录,跳转到登录页"的死循环if(to.path ==='/login'){next()return}// 有Token?说明登录了,放行,想去哪去哪if(hasToken){next()}else{// 没Token?不好意思,记下你想去哪,先去登录页报到// 这里用query参数把目标地址带过去,登录成功后能跳回来next(`/login?redirect=${encodeURIComponent(to.fullPath)}`)}})exportdefault router // utils/auth.js 简单的Token管理const TokenKey ='Admin-Token'exportfunctiongetToken(){return localStorage.getItem(TokenKey)}exportfunctionsetToken(token){return localStorage.setItem(TokenKey, token)}exportfunctionremoveToken(){return localStorage.removeItem(TokenKey)}// views/login/index.vue 登录页逻辑exportdefault{methods:{asynchandleLogin(){try{// 调登录接口const{ data }=awaitlogin({username:this.username,password:this.password })// 存TokensetToken(data.token)// 登录成功,看看有没有之前被拦截时记下的目标地址const redirect =this.$route.query.redirect ||'/'this.$router.push(redirect)this.$message.success('登录成功')}catch(error){this.$message.error(error.message ||'登录失败')}}}}这套代码的核心就是router.beforeEach,每次路由变化它都会执行。注意那个判断to.path === '/login'的代码,千万别漏,不然就会出现"检查没登录→跳登录页→检查没登录→跳登录页"的无限循环,浏览器直接卡死。
React Router v6 实现:
React Router v6之后,API变了很多,没有直接的全局守卫了,但思路一样,用自定义组件包装。
// components/AuthRoute.jsx import { Navigate, useLocation } from 'react-router-dom' import { getToken } from '../utils/auth' // 这是一个高阶组件,包装了权限检查逻辑 const AuthRoute = ({ children }) => { const location = useLocation() const hasToken = getToken() // 没登录?重定向到登录页,并记下当前位置 if (!hasToken) { return <Navigate to="/login" state={{ from: location }} replace /> } // 登录了?正常渲染子组件 return children } export default AuthRoute // App.jsx 路由配置 import { BrowserRouter, Routes, Route } from 'react-router-dom' import AuthRoute from './components/AuthRoute' import Login from './pages/Login' import Dashboard from './pages/Dashboard' import UserList from './pages/UserList' function App() { return ( <BrowserRouter> <Routes> {/* 登录页不需要权限检查 */} <Route path="/login" element={<Login />} /> {/* 其他页面都用AuthRoute包一层 */} <Route path="/dashboard" element={ <AuthRoute> <Dashboard /> </AuthRoute> } /> <Route path="/users" element={ <AuthRoute> <UserList /> </AuthRoute> } /> {/* 首页重定向 */} <Route path="/" element={<Navigate to="/dashboard" />} /> </Routes> </BrowserRouter> ) } export default App // pages/Login.jsx import { useNavigate, useLocation } from 'react-router-dom' import { setToken } from '../utils/auth' const Login = () => { const navigate = useNavigate() const location = useLocation() const handleSubmit = async (values) => { try { const { data } = await loginApi(values) setToken(data.token) // 登录成功后,跳转到之前被拦截的页面,或者默认跳首页 const from = location.state?.from?.pathname || '/dashboard' navigate(from, { replace: true }) } catch (error) { message.error('登录失败') } } return ( // ... 登录表单 ) } React这边稍微麻烦点,每个路由都要手动包一层AuthRoute。但好处是更灵活,你可以给不同的路由传不同的参数,比如<AuthRoute requiredRole="admin">,实现更细粒度的控制。
方案二:路由元信息(Meta)配置流——VIP室需要特殊通行证
全局拦截太粗暴了,有时候我们希望"大部分页面随便看,但某些敏感页面必须登录"。这时候可以用路由的meta字段,给每个路由贴标签。
Vue Router 实现:
// router/routes.jsexportconst routes =[{path:'/',name:'Home',component:()=>import('@/views/home/index.vue'),meta:{title:'首页',requiresAuth:false// 不需要登录}},{path:'/products',name:'Products',component:()=>import('@/views/products/index.vue'),meta:{title:'产品列表',requiresAuth:false// 公开页面}},{path:'/cart',name:'Cart',component:()=>import('@/views/cart/index.vue'),meta:{title:'购物车',requiresAuth:true// 必须登录}},{path:'/admin',name:'Admin',component:()=>import('@/views/admin/index.vue'),meta:{title:'管理后台',requiresAuth:true,requiredRoles:['admin','super_admin']// 还需要特定角色}},{path:'/login',name:'Login',component:()=>import('@/views/login/index.vue'),meta:{title:'登录',requiresAuth:false,hideInMenu:true// 不显示在导航菜单}}]// router/index.js 升级版的守卫import router from'./router'import{ getToken, getUserInfo }from'@/utils/auth' router.beforeEach(async(to, from, next)=>{// 设置页面标题 document.title = to.meta.title ||'默认标题'const hasToken =getToken()// 如果页面不需要认证,直接放行if(!to.meta.requiresAuth){next()return}// 需要认证但没Token,去登录if(!hasToken){next(`/login?redirect=${to.path}`)return}// 有Token,但还要检查角色权限(如果有配置的话)if(to.meta.requiredRoles){// 这里假设用户信息存在Vuex或已经通过接口获取const userInfo =getUserInfo()// { id: 1, name: '张三', roles: ['admin'] }const hasRole = to.meta.requiredRoles.some(role=> userInfo.roles.includes(role))if(!hasRole){// 没权限?去403页面,或者弹窗提示next('/403')return}}// 所有检查都通过了,放行next()})// utils/auth.js 升级版,带用户信息const TokenKey ='Admin-Token'const UserKey ='User-Info'exportfunctiongetToken(){return localStorage.getItem(TokenKey)}exportfunctionsetToken(token){ localStorage.setItem(TokenKey, token)}exportfunctiongetUserInfo(){const userStr = localStorage.getItem(UserKey)return userStr ?JSON.parse(userStr):null}exportfunctionsetUserInfo(user){ localStorage.setItem(UserKey,JSON.stringify(user))}这套方案的好处是配置化,每个路由的权限要求一目了然。而且你可以扩展meta字段,加requiredPermissions(需要的权限点)、keepAlive(是否缓存)等等,很灵活。
React Router v6 实现:
// router/config.js export const routes = [ { path: '/', element: <Home />, meta: { requiresAuth: false, title: '首页' } }, { path: '/profile', element: <Profile />, meta: { requiresAuth: true, title: '个人中心' } }, { path: '/admin', element: <Admin />, meta: { requiresAuth: true, requiredRoles: ['admin'], title: '管理后台' } } ] // components/ProtectedRoute.jsx import { Navigate, useLocation } from 'react-router-dom' import { getToken, getUserInfo } from '../utils/auth' const ProtectedRoute = ({ route, children }) => { const location = useLocation() const hasToken = getToken() const meta = route.meta || {} // 不需要认证,直接过 if (!meta.requiresAuth) { return children } // 需要认证但没登录 if (!hasToken) { return <Navigate to="/login" state={{ from: location }} replace /> } // 需要特定角色 if (meta.requiredRoles) { const userInfo = getUserInfo() const hasRole = meta.requiredRoles.some(role => userInfo?.roles?.includes(role) ) if (!hasRole) { return <Navigate to="/403" replace /> } } return children } // 在App.jsx中使用 <Route path="/admin" element={ <ProtectedRoute route={adminRouteConfig}> <Admin /> </ProtectedRoute> } /> 方案三:高阶组件(HOC)包装术——React老哥的优雅之选
如果你用React,而且不喜欢在每个路由配置里写一堆逻辑,可以用HOC(Higher-Order Component)把权限逻辑抽离出来,组件本身只管渲染,清爽。
// hocs/withAuth.jsx import { Component } from 'react' import { Navigate } from 'react-router-dom' import { getToken, getUserInfo } from '../utils/auth' // 这是一个高阶组件工厂,接收配置参数,返回一个包装函数 const withAuth = (options = {}) => (WrappedComponent) => { // options可以配置:requiredRoles, requiredPermissions等 return class AuthComponent extends Component { render() { const hasToken = getToken() // 没登录,去登录页 if (!hasToken) { return <Navigate to="/login" replace /> } // 需要角色检查 if (options.requiredRoles) { const userInfo = getUserInfo() const hasRole = options.requiredRoles.some(role => userInfo?.roles?.includes(role) ) if (!hasRole) { return <div>403 Forbidden - 你没有权限访问此页面</div> } } // 所有检查通过,渲染被包装的组件,并注入用户信息 const userInfo = getUserInfo() return <WrappedComponent {...this.props} userInfo={userInfo} /> } } } export default withAuth // pages/AdminDashboard.jsx import withAuth from '../hocs/withAuth' const AdminDashboard = ({ userInfo }) => { // 组件里可以直接拿到用户信息,不用自己再调接口 return ( <div> <h1>欢迎回来,{userInfo.name}</h1> <p>你的角色:{userInfo.roles.join(', ')}</p> {/* 管理后台内容 */} </div> ) } // 导出的时候用HOC包一下,配置权限要求 export default withAuth({ requiredRoles: ['admin', 'super_admin'] })(AdminDashboard) // 更高级的用法:组合多个HOC import withAuth from '../hocs/withAuth' import withLayout from '../hocs/withLayout' import withLoading from '../hocs/withLoading' // 先检查权限,再套布局,最后加Loading效果 export default withAuth({ requiredRoles: ['admin'] })( withLayout('admin')( withLoading(AdminDashboard) ) ) HOC的好处是复用性极强,而且符合React"组合优于继承"的理念。但缺点也很明显:嵌套太深的时候,调试起来像剥洋葱,一层一层又一层,眼泪都要出来了。而且函数组件时代,Hooks其实能替代大部分HOC的场景,所以现在很多React项目更倾向于用自定义Hook。
对比Vue Router和React Router的"小心机":
Vue Router是"配置驱动",路由表写好了,守卫逻辑也写好了,剩下的它帮你调度,很省心。但有时候太自动了,你想在某个特定场景跳过守卫?得用next()的各种骚操作,容易搞晕。
React Router是"组件驱动",一切都是组件,路由是组件,守卫也是组件,灵活是灵活,但 boilerplate 代码多,每个路由都要手动包一层。而且v5到v6的API变化巨大,很多老项目还在用v5,新教程都是v6,照着文档抄都能抄出Bug。
Vue的beforeEach是同步执行的(除非你显式返回Promise),逻辑顺序很清晰。React的守卫逻辑写在组件里,涉及到异步获取用户信息的时候,很容易出现"组件已经mount了,权限检查还没完成"的闪屏问题,得用useState配合useEffect小心处理Loading状态。
这玩意儿真香吗?也不全是,有些坑你得提前绕
说了这么多好处,咱也得客观聊聊这些方案的阴暗面,别到时候上线了才发现"当时为啥没人告诉我这个"。
优点确实很明显:
用户体验好是真的。不该看的页面直接拦在门外,用户不会看到那种"页面加载了一半,突然弹个框说没权限"的尴尬场面。而且前端拦截是瞬间完成的,不用等后端接口返回401,那个Loading转圈圈的时间省了,感觉系统响应很快。
安全性也有提升。虽然前端拦截防不了真正的高手(人家直接调你接口),但能防住90%的普通用户和测试人员的误操作。而且把路由结构藏起来,也增加了攻击者的信息收集成本。
但缺点也很头疼:
动态菜单怎么搞? 这是个大坑。很多后台系统的左侧菜单是根据用户权限动态生成的,管理员看到10个菜单,普通用户只看到5个。如果你在前端硬编码路由表,那没权限的用户虽然进不去页面,但菜单上还能看到那个入口,点进去提示无权限,这体验就很诡异。
解决方案是后端返回菜单配置,前端动态生成路由。但这就涉及到"异步获取权限信息"的问题——用户刷新页面,Token还在,但用户信息(包括权限列表)需要调接口获取,这个异步过程还没完成的时候,路由守卫已经执行了,怎么破?
// Vue Router解决"异步权限"问题// store/modules/user.jsimport{ getToken, getUserInfo }from'@/utils/auth'import{ fetchUserProfile }from'@/api/user'const state ={token:getToken(),userInfo:getUserInfo(),// 可能为null,因为只存了Token没存详细信息roles:[],// 权限角色permissions:[],// 权限点hasFetchedInfo:false// 标记是否已经获取过用户信息}const actions ={// 获取用户信息的ActionasyncgetInfo({ commit, state }){// 如果已经有信息了,直接返回,避免重复请求if(state.hasFetchedInfo){return{roles: state.roles,permissions: state.permissions }}// 调接口获取const{ data }=awaitfetchUserProfile()commit('SET_ROLES', data.roles)commit('SET_PERMISSIONS', data.permissions)commit('SET_USER_INFO', data)commit('SET_HAS_FETCHED_INFO',true)return data }}// router/index.js 改造守卫import store from'@/store' router.beforeEach(async(to, from, next)=>{const hasToken =getToken()if(to.path ==='/login'){next()return}if(!hasToken){next('/login')return}// 有Token,但还没获取用户信息(刷新页面后的情况)if(!store.state.user.hasFetchedInfo){try{// 等待获取用户信息await store.dispatch('user/getInfo')// 获取成功后,重新走一遍路由守卫(因为动态路由可能刚添加)next({...to,replace:true})}catch(error){// 获取失败(Token过期等),清除登录状态await store.dispatch('user/logout')next(`/login?redirect=${to.path}`)}return}// 已经有用户信息了,正常检查权限if(to.meta.requiredRoles){const hasRole = to.meta.requiredRoles.some(role=> store.state.user.roles.includes(role))if(!hasRole){next('/403')return}}next()})异步获取用户信息时页面闪白的问题,上面代码用await store.dispatch解决了,但代价是路由跳转会阻塞,用户体验是"点击菜单,卡一下,然后页面出来"。如果你希望先显示Loading,可以用路由懒加载配合组件内的Loading状态,但代码复杂度就上去了。
"死循环"陷阱是新手最常踩的坑。我前面提过,守卫里跳转到登录页,但登录页本身也被守卫拦截了,就会无限循环。还有一种情况是,获取用户信息失败(Token过期),守卫里执行logout然后跳登录页,但logout是异步的,还没清完Token就执行了跳转,结果跳转过去还有Token(清了一半),又触发守卫,又获取信息,又失败,又跳转…
// 错误的写法,可能导致死循环 router.beforeEach((to, from, next)=>{if(!getToken()&& to.path !=='/login'){next('/login')}// 缺少else分支,当to.path === '/login'时,没有调用next(),路由不会继续})记住,守卫里必须有且只有一次next()调用,而且逻辑分支要覆盖所有情况。
微前端场景简直就是修罗场。主子应用都有各自的路由器,权限校验怎么协调?主应用登录了,子应用怎么知道?子应用的路由被拦截了,是子应用自己跳登录页,还是通知主应用统一处理?URL怎么同步?这些问题没有标准答案,得根据你的微前端方案(qiankun、Module Federation等)具体设计。一般建议是权限校验统一在主应用做,子应用只负责渲染,但这样子应用单独运行的时候就废了。或者子应用也保留一套简单的校验,主应用传Token下来。总之很头大,非必要别上微前端。
真实项目里的那些"骚操作"和血泪史
理论讲完,分享几个我在生产环境踩过的坑,以及怎么爬出来的。
Token过期无感刷新
用户正在愉快地操作,突然Token过期了,调接口返回401,这时候如果直接踢去登录页,用户刚才填的表单数据就全丢了,他会杀了你。
解决方案是双Token机制:Access Token(短期,比如2小时)+ Refresh Token(长期,比如7天)。Access Token过期了,用Refresh Token换一个新的,用户无感知。
// utils/request.js Axios拦截器实现import axios from'axios'import{ getToken, setToken, removeToken, getRefreshToken }from'./auth'import router from'@/router'let isRefreshing =false// 标记是否正在刷新Tokenlet requestsQueue =[]// 刷新期间被阻塞的请求队列const service = axios.create({baseURL: process.env.VUE_APP_BASE_API,timeout:5000})// 请求拦截器,统一加Token service.interceptors.request.use(config=>{const token =getToken()if(token){ config.headers['Authorization']='Bearer '+ token }return config },error=>{return Promise.reject(error)})// 响应拦截器,处理401和刷新Token service.interceptors.response.use(response=> response.data,asyncerror=>{const{ response, config }= error // 如果是401且不是因为刷新Token接口本身报的错if(response?.status ===401&&!config._retry){const refreshToken =getRefreshToken()if(!refreshToken){// 没有Refresh Token,直接踢去登录removeToken() router.push('/login')return Promise.reject(error)}// 如果正在刷新,把请求加入队列,等刷新完再重试if(isRefreshing){returnnewPromise(resolve=>{ requestsQueue.push(()=>{ config.headers['Authorization']='Bearer '+getToken()resolve(service(config))})})}// 开始刷新 isRefreshing =true config._retry =truetry{// 调刷新接口const{ data }=await axios.post('/auth/refresh',{refreshToken: refreshToken })// 存新TokensetToken(data.accessToken)// Refresh Token也可能更新if(data.refreshToken){setRefreshToken(data.refreshToken)}// 重试刚才失败的请求 config.headers['Authorization']='Bearer '+ data.accessToken // 执行队列里等待的请求 requestsQueue.forEach(callback=>callback()) requestsQueue =[]returnservice(config)}catch(refreshError){// 刷新也失败了,彻底凉凉removeToken()removeRefreshToken() router.push('/login')return Promise.reject(refreshError)}finally{ isRefreshing =false}}return Promise.reject(error)})exportdefault service 这段代码的关键是isRefreshing标记和requestsQueue队列。当Token过期时,如果同时有多个请求发出,第一个请求会触发刷新,其他请求不会重复刷新,而是加入队列等刷新完成后一起重试。这样用户正在填的表单数据不会丢,操作也不会中断。
动态路由表生成
硬编码路由表太low了,高级玩法是从后端拉取配置,动态挂载路由。
// 后端返回的路由配置示例const serverRoutes =[{path:'/user',component:'Layout',// 对应前端的布局组件children:[{path:'list',component:'user/list',// 对应views/user/list.vuemeta:{title:'用户列表',permission:'user:view'}},{path:'detail/:id',component:'user/detail',meta:{title:'用户详情',permission:'user:detail'}}]}]// 前端处理函数functiongenerateRoutes(serverRoutes){const routes =[] serverRoutes.forEach(item=>{const route ={path: item.path,component: item.component ==='Layout'?()=>import('@/layout/index.vue'):()=>import(`@/views/${item.component}.vue`),// 动态导入meta: item.meta,children: item.children ?generateRoutes(item.children):[]} routes.push(route)})return routes }// 在获取用户信息后,动态添加路由const accessRoutes =generateRoutes(serverRoutes) accessRoutes.forEach(route=>{ router.addRoute(route)// Vue Router 4.x的API,动态添加路由})注意router.addRoute是Vue 3.x的API,Vue 2.x是router.addRoutes(复数)。动态添加的路由会立即生效,但刷新页面后会丢失,所以需要在beforeEach里重新添加,或者存到Vuex/Pinia里持久化。
按钮级别的权限控制
拦住页面不够,页面上的按钮也得控制。比如"删除"按钮,只有拥有user:delete权限的人才看得见。
<!-- Vue指令实现 --> <template> <div> <button v-permission="'user:create'">新增用户</button> <button v-permission="'user:delete'">删除用户</button> <button v-permission="'user:export'">导出Excel</button> </div> </template> <script> // directives/permission.js import { getUserPermissions } from '@/utils/auth' export default { mounted(el, binding) { const { value } = binding const permissions = getUserPermissions() // ['user:view', 'user:create'] if (!permissions.includes(value)) { // 没权限,移除按钮(或者disabled) el.parentNode && el.parentNode.removeChild(el) // 或者 el.style.display = 'none' // 或者 el.disabled = true } } } // 注册全局指令 // main.js import permission from './directives/permission' Vue.directive('permission', permission) </script> // React实现,用Hook import { usePermission } from '../hooks/usePermission' const UserList = () => { const { hasPermission } = usePermission() return ( <div> {hasPermission('user:create') && ( <button onClick={handleCreate}>新增用户</button> )} {hasPermission('user:delete') && ( <button onClick={handleDelete}>删除用户</button> )} </div> ) } // hooks/usePermission.js import { getUserPermissions } from '../utils/auth' export const usePermission = () => { const permissions = getUserPermissions() || [] const hasPermission = (permission) => { return permissions.includes(permission) } const hasAnyPermission = (permissionList) => { return permissionList.some(p => permissions.includes(p)) } const hasAllPermissions = (permissionList) => { return permissionList.every(p => permissions.includes(p)) } return { hasPermission, hasAnyPermission, hasAllPermissions } } 多租户系统的数据隔离
之前做过一个SaaS系统,多个公司共用一套代码,但数据要完全隔离。路由层面要做到:A公司的人输入B公司的URL,要么404,要么提示无权限。
// 路由守卫里加租户检查 router.beforeEach(async(to, from, next)=>{const hasToken =getToken()const currentTenant =getCurrentTenant()// 从Token解析或从URL参数获取// 检查URL里的租户ID是否和当前用户一致const routeTenantId = to.params.tenantId if(routeTenantId && routeTenantId !== currentTenant.id){// 尝试访问其他租户的数据,直接403next('/403')return}// 或者更严格:所有路由都必须带租户ID前缀// /:tenantId/dashboard,不匹配当前租户的,直接拦截})报错了别慌,这套排查思路能救你的狗命
线上出问题了,用户说"进不去页面",别急着改代码,按这个顺序排查:
第一步:看Network,确认Token带没带出去
打开浏览器控制台,找到那个报401的请求,看Request Headers里有没有Authorization: Bearer xxx。如果没有,说明前端没拿到Token,检查localStorage里存了没,或者存的时候key名写错了(比如token写成了Token)。如果有Token但还是401,说明Token过期了或后端不认,找后端对一下Token生成逻辑。
第二步:检查路由配置的meta字段
是不是meta: { requiresAuth: true }手抖写成了false?或者写成了requireAuth(少了s)?这种拼写错误我犯过不止一次,守卫里判断的是requiresAuth,配置里写的是requireAuth,结果守卫判断undefined为假,直接放行了。
第三步:留意异步加载的时机
守卫里调了await store.dispatch('user/getInfo'),但组件里又await fetchData(),两个异步并行,结果fetchData先执行了,这时候Token还没被拦截器加上,接口报了401。解决办法是在组件里等用户信息获取完成再调接口,或者在路由守卫里等所有前置条件完成再next()。
第四步:清理缓存!清理缓存!清理缓存!
很多时候不是代码错了,是浏览器在耍流氓。特别是用了PWA或Service Worker的,缓存策略太激进,新版本代码没生效。Ctrl+F5强制刷新,或者Application标签页里Clear Storage,再试。我有一次折腾了两个小时,最后发现是Chrome Extension拦截了请求,关了扩展就好了,气得我差点把键盘砸了。
老鸟私藏的防脱发小技巧,一般人我不告诉他
拦截器配合守卫,一套代码走天下
把HTTP拦截器和路由守卫打通,拦截器负责自动加Token和处理401,守卫负责路由层面的拦截,两者各司其职但共享认证状态。
// 拦截器里处理401,通知守卫 service.interceptors.response.use(response=> response,error=>{if(error.response?.status ===401){// 触发全局事件或修改全局状态 window.dispatchEvent(newCustomEvent('auth:expired'))}return Promise.reject(error)})// 守卫里监听这个事件 window.addEventListener('auth:expired',()=>{ router.push('/login')})SPA的SEO问题(虽然有点矛盾)
路由保护了,但搜索引擎爬虫爬不到内容,怎么办?说实话,后台管理系统不需要SEO,但如果是前台页面需要登录才能看(比如付费内容),可以用预渲染(Prerendering)或服务端渲染(SSR)时根据User-Agent判断,是爬虫就返回简化版内容,是用户就正常走权限校验。
降级处理:权限服务挂了怎么办
万一后端获取权限配置的接口挂了,是让所有人进不去(安全优先),还是默认放行(可用性优先)?这得看老板心情和业务场景。金融系统肯定选前者,内部工具可以选后者。建议加个配置开关:
// 守卫里的降级逻辑try{awaitfetchUserPermissions()}catch(error){if(process.env.VUE_APP_AUTH_FAIL_SAFE==='true'){// 降级:使用本地缓存的权限,或默认放行基础页面next()}else{// 严格:拒绝访问next('/system-error')}}别过度设计
小项目就几个页面,别搞什么RBAC(基于角色的访问控制)模型了,几个if-else能解决的事,别整花活。我见过一个只有3个页面的后台系统,作者硬是搞了一套完整的权限配置平台,可以动态配置角色、权限、菜单,结果配置的时间比写页面还长,而且那系统上线两年,权限配置从来没变过,纯纯的过度设计。
行了,今天就唠到这,愿你的线上永远没403
记住,权限保护不是为了炫技,是为了让半夜报警电话少响几次。没有什么比凌晨3点被运维电话吵醒,说"系统被爬虫爬崩了"或"数据泄露了"更可怕的了。
下次再有人问你怎么做路由保护,直接把这篇文章甩过去,深藏功与名。如果看完还是不会,那可能是咱俩缘分未到,或者你该换个框架试试了(开玩笑的,别打我)。
赶紧去修Bug吧,毕竟产品经理又在群里催新需求了。祝大家好运,头发茂密,线上稳定,永无Bug。
