# 16 | 实战痛点2:项目开发中的权限系统 你好,我是大圣。 在上一讲中,我们使用组件库完成了项目的搭建,并且引入了Element3和axios等基础库。基于Element3,我们可以很方便地使用组件搭建项目。而使用axios,可以很方便地获取后端数据。在项目中,权限系统的控制需要前后端配合完成,而且权限系统也是后端管理系统中常见的一个难点。不过,今天我们主要从前端的角度,来聊一下项目的权限系统。 下面,我们先从登录权限谈起,因为登录权限对于一个项目来说是必备的功能模块。完成了登录选项的设置后,下一步需要做的是管理项目中的页面权限,而角色权限在这一过程中则可以帮助我们精细化地去控制页面权限。 ## 登录权限 继续上一讲我们搭建起来的项目,你可以看到现在所有的页面都可以直接访问了,通常来说管理系统的内部页面都需要登录之后才可以访问,比如个人中心、订单页面等等。首先,我们来设计一个这样的权限限制功能,它能保证某些页面在登录之后才能访问。 为了实现这个功能,我们首先需要模拟登录的接口和页面。我们先新增路由页面,进入到项目目录下,在router.js中新增路由配置。下面的代码中,routes数组新增/login路由访问。 ```javascript import Login from '../components/Login.vue' const routes = [ ... { path: '/login', component: Login, hidden: true, } ] ``` 然后,我们进入到src/components/Login.vue组件中,组件的代码如下所示。在代码中,我们能看到,用户在输入用户名和密码之后,把用户名和密码传递给后端,然后就可以实现登录认证。 ```javascript handleLogin() { formRef.value.validate(async valid => { if (valid) { loading.value = true const {code, message} = await useStore.login(loginForm) loading.value = false if(code===0){ router.replace( toPath || '/') }else{ message({ message: '登录失败', type: 'error' }) } } else { console.log('error submit!!') return false } }) } ``` 由于我们的项目是一个前端项目,所以我们需要在Vite内部做数据结构的模拟。我们在src目录下面新建mock目录,用来放置假数据的结构。我们写死一个用户名dasheng,使用用户名dasheng登录成功之后,我们把用户名、过期日期等重要信息进行加密,生成一个token返回给前端。 **这个token就算是一个钥匙,****对于那些****需要权限****才能读取到****的页面数据,前端需要带上这个钥匙才能读取到数据,否则****访问那些页面的时候****就会显示没有权限**。 ```javascript { url: '/geek-admin/user/login', type: 'post', response: config => { const { username } = config.body const token = tokens[username] // mock error if (user!=='dasheng') { return { code: 60204, message: 'Account and password are incorrect.' } } return { code: 20000, data: token } } } ``` 我们回到前端页面,登录成功后,首先需要做的事情,就是把这个token存储在本地存储里面,留着后续发送数据。这一步的实现比较简单,直接把token存储到localStorage中就可以了。我们拿到这个token后,为了进行接口权限认证,要把token放在HTTP请求的header内部。 我们看下面的代码,在axios的请求发出之前,我们在配置中使用getToken从localStorage中读取token,放在请求的header里发送。由于我们使用了请求拦截的方式,所以所有的后端数据发送的时候,都会带上这个token,完成受限数据的请求。 ```javascript service.interceptors.request.use( config => { const token = getToken() // do something before request is sent if (token) { config.headers.gtoken = token } return config }, error => { // do something with request error console.log(error) // for debug return Promise.reject(error) } ) ``` 通过上面的操作,我们完成了前端网络请求的token限制。但是还有一个需求没有实现,就是用户没有登录某个受限页面的时候,或者说没有token的时候,如果直接访问受限页面,比如个人中心,那么就需要让vue-router拦截这次页面的跳转。 与vue-router拦截页面的跳转,并显示无权限的报错信息相比,直接跳转登录页是现在更流行的交互方式。但这种方式需要在vue-router上加一层限制,这层限制就是说,在路由跳转的时候做权限认证,我们把vue-router的这个功能称作导航守卫。 关于导航守卫的API,你可以从Vue Router的官网看到很详细的[介绍](https://next.router.vuejs.org/zh/guide/advanced/navigation-guards.html)。这里我们实际应用下,下面的代码中,我们在router.beforeEach函数中设置一个全局的守卫。 每次页面跳转的时候,vue-router会自动执行守卫函数,如果函数返回false的话,页面跳转就会失败。不过,我们也可以手动地跳转到其他页面。现在我们设置的路由很简单,如果token不存在的话直接跳转登录页面,否则返回true,页面正常跳转。 ```javascript router.beforeEach(async (to, from,next) => { // canUserAccess() 返回 `true` 或 `false` let token = getToken() if(!token){ next('/login') } return true }) ``` 当然,在路由守卫的函数内,只要是页面跳转时想实现的操作,都可以放在这个函数内部实现,比如一些常见的交互效果,就像给项目的主页面顶部设置一个页面跳转的进度条、设置和修改页面标题等等。和我们现在对全部页面进行一次性的粗略拦截相比,后面还会在路由守卫那里进行更精确的路由拦截。 到这里你可能会有疑问:之前开发项目的时候,和登录注册相关的配置,不需要自己管理token,都是后端直接设置cookie。那么这里用到的token和之前项目开发时,交给后端设置的cookie到底有什么区别呢? 这是个非常好的问题,我们在第一讲聊前端发展史的时候,提到了jQuery时代的前端项目是作为后端项目的模块部署的。 那时候前后端不分家,整个应用的入口是后端控制模板的渲染。在模板渲染前,后端会直接判断路由的权限来决定是否跳转。登录的时候,后端只需要设置setCookie这个header,之后浏览器会自动把cookie写入到我们的浏览器存起来,然后当前域名在发送请求的时候都会自动带上这个cookie。 在Chrome浏览器中,我们先进入极客时间的官网,然后打开调试窗口页面,再选择Network页面。之后,我们在页面中点击Fetch/XHR,然后在Name这一栏中,我们可以任选一个接口点开。这样,我们就可以看到这个接口请求的所有细节了。 在下图中,我们点击list请求,也就是极客时间的推荐接口时,HTTP的Request Headers里就有Cookie这个数据,这是浏览器自动管理和发送的,也算是权限认证的最佳方案之一。 ![图片](https://static001.geekbang.org/resource/image/98/8e/98b1ce4137ecfb9ea01dc4ea0381e68e.png?wh=1920x1084) 但是,在现在这种前后端分离的场景下,通常前后端项目都会部署在不同的机器和服务器之上,Cookie在跨域上有诸多的限制。所以在这种场景下,我们更愿意手动地去管理权限,于是就诞生了现在流行的基于token的权限解决方案,你也可以把token理解为我们手动管理的cookie。 ## 角色权限 实现登录权限验证之后,我们就可以针对项目中的页面进行登录的保护。但现在,我们只能通过登录状态去判断页面的显示与否,而这远远达不到我们实际开发的需求。 比如,在我们的管理系统开发中,订单页面是所有人都可以看到的,但是像账单的查询页面,以及其他一些权限更高的页面,我们需要管理员权限才能看到。这时候,我们就需要对系统内部的权限进行分级,每个级别都对应着可以访问的不同页面。 我们通常使用的权限解决方案就是RBAC权限管理机制。简单来说,就是在下图所示的这个模型里,除了用户和页面之外,**我们需要一个新的概念,就是角色****。**每个用户有不同的角色,每个角色对应不同的页面权限,这个数据结构的关系设计主要是由后端来实现。 根据下图这个结构,在用户登录完成之后我们会获取页面的权限数据,也就是说后端会返回给我们当前页面的动态权限部分。 ![图片](https://static001.geekbang.org/resource/image/e9/95/e93dd5a15c93b193debe462eb0349c95.jpg?wh=1347x697) 这样有一部分页面是写在代码的src/router/index.js中,另外一部分页面我们通过axios获取数据后,通过调用vue-router的addRoute方法动态添加进项目整体的路由配置中。 关于这部分动态路由的内容,官网的文档中有详细的[API介绍](https://next.router.vuejs.org/zh/guide/advanced/dynamic-routing.html)。在下面的代码中,我们在Vuex中注册addRoute这个action,通过后端返回的权限页面数据,调用router.addRoute新增路由。 ```javascript addRoutes({ commit }, accessRoutes) { // 添加动态路由,同时保存移除函数,将来如果需要重置路由可以用到它们 const removeRoutes = [] accessRoutes.forEach(route => { const removeRoute = router.addRoute(route) removeRoutes.push(removeRoute) }) commit('SET_REMOVE_ROUTES', removeRoutes) }, ``` 与新增路由对应,在页面重新设置权限的时候,我们需要用router.removeRoute来删除注册的路由,这也是上面的代码中我们还有一个remoteRoutes来管理动态路由的原因。 然后,我们需要把动态路由的状态存储在本地存储里,否则刷新页面之后,动态的路由部分就会被清空,页面就会显示404报错。我们需要在localStorage中把静态路由和动态路由分开对待,在页面刷新的时候,通过src/router/index.js入口文件中的routes配置,从localStorage中获取完整的路由信息,并且新增到vue-router中,才能加载完整的路由。 权限系统中还有一个常见的问题,就是登录是有时间限制的。在常见的登录状态下,token有效期只能保持24小时或者72小时,过了这个期限,token会自动失效。即使我们依然存在token,刷新页面后也会跳转到登录页。所以,对token有效期的判断这个需求该如何实现呢? 首先,token的过期时间认证是由后端来实现和完成的。如果登录状态过期,那么会有一个单独的报错信息,我们需要在接口拦截函数中,统一对接口的响应结果进行拦截。如果报错信息显示的是登录过期,我们需要清理所有的token和页面权限数据,并且跳转到登录页面。 下面的代码中,50008和50012都代表着状态过期,我们可以直接使用Element3的messageBox组件显示一个错误信息,提示用户需要重新登录,然后直接跳转到登录页面就可以了。 ```javascript // 50008: Illegal token; 50012: Other clients logged in; 50014: Token expired; if (res.code === 50008 || res.code === 50012) { // to re-login Msgbox.confirm('You have been logged out, you can cancel to stay on this page, or log in again', 'Confirm logout', { confirmButtonText: 'Re-Login', cancelButtonText: 'Cancel', type: 'warning', }).then(() => { store.dispatch('user/resetToken').then(() => { location.reload() }) }) } return Promise.reject(new Error(res.message || 'Error')) ``` ## 总结 今天这一讲的内容就讲完了,我们来总结一下今天学到的重点吧。首先,在登录权限这一部分,我们模拟了登录的接口和页面,登录成功后的token存储在localStorage中,使用axios的拦截器管理所有接口的token信息。 然后,我们学习了路由守卫的概念,借助路由守卫,我们可以实现页面的权限保护,让指定页面需要登录之后才可以访问。我们使用的是token权限校验机制,在用户登录之后,后端会把用户信息加密成一个token返回给前端,然后前端把token存储在localStorage中。 在axios的全局拦截函数中,每个请求发出去之前都会获取token,然后放在HTTP请求的header之中,后端会读取这个token进行权限认证。除了后端接口的权限,vue-router也会做页面的权限拦截,这个功能也就是路由守卫,vue-router允许你在所有路由跳转之前执行钩子函数,这个函数内部我们做权限认证,如果符合要求就允许跳转,否则就跳转到404页面。 最后,我们还了解了更复杂一些的权限系统设计,在这种设计下,用户可以根据角色的划分,对应到不同的页面权限,一部分页面是登录后就可以访问,一部分页面是需要额外地获取后端接口,匹配到当前用户权限之后,才可以访问的页面,我们把这种权限设计称之为动态路由。vue-router提供的addRoute和removeRoute这两个函数,可以很好地帮助我们实现这一功能。 ## 思考题 最后,是给你留的一个思考题,如果在任意一个页面里,我们想实现按钮级别的权限认证,那我们应该如何扩展我们的权限系统呢? 欢迎在留言区分享你的看法,也欢迎你把这一讲的内容分享给你的同事、朋友们,我们下一讲再见。