【www.hj8828.com】Vue-Access-Control:前端用户权限控制解决方案

基于 Vue 的实现方案

目录结构

src/
  |-- api/                  //接口文件
  |     |-- index.js             //输出通用axios实例
  |     |-- account.js           //按业务模块组织的接口文件,所有接口都引用./index提供的axios实例
  |-- assets/
  |-- components/
  |-- router/
  |     |-- fullpath.js         //完整路由数据,用于匹配用户的路由权限得到实际路由
  |     `-- index.js            //输出基础路由实例
  |-- views/
  |-- App.vue
  ·-- main.js

 

动态注册路由

最初实例化的路由仅包括登录和404两个路径,我们期待完整的路由是这样的:

[{
  path: '/login',
  name: 'login',
  component: (resolve) => require(['../views/login.vue'], resolve)
}, {
  path: '/404',
  name: '404',
  component: (resolve) => require(['../views/common/404.vue'], resolve)
}, {
  path: '/',
  name: '首页',
  component: (resolve) => require(['../views/index.vue'], resolve),
  children: [{
    path: '/route1',
    name: '栏目1',
    meta: {
      icon: 'icon-channel1'
    },
    component: (resolve) => require(['../views/view1.vue'], resolve)
  }, {
    path: '/route2',
    name: '栏目2',
    meta: {
      icon: 'ico-channel2'
    },
    component: (resolve) => require(['../views/view2.vue'], resolve),
    children: [{
      path: 'child2-1',
      name: '子栏目2-1',
      meta: {

      },
      component: (resolve) => require(['../views/route2-1.vue'], resolve)
    }]
  }]
}, {
  path: '*',
  redirect: '/404'
}]

  

那么接下来就需要获取首页以及其子路由们,思路是事先在本地存一份整个项目的完整路由数据,然后根据用户权限对完整路由进行筛选。

筛选的实现思路是先将后端返回的路由数据处理成如下哈希结构:

let hashMenus = {
   "/route1":true,
   "/route1/route1-1":true,
   "/route1/route1-2":true,
   "/route2":true,
   ...
}

  

然后遍历本地完整路由,在循环中将路径拼接成上述结构中的key格式,通过hashMenus[route]就可以判断路由是否匹配,具体实现见App.vue文件中的getRoutes()方法。

如果后端返回的路由权限数据与约定不同,就需要自行实现筛选逻辑,只要能得到实际可用的路由数据就可以,最终使用addRoutes()方法将他们动态添加到路由实例中,注意404页面的模糊匹配一定要放在最后。

请求控制

请求控制是利用 axios 拦截器实现的,原理是在请求拦截器中获取本次请求的
url 和 method
信息,再与资源权限数据做比对,判断请求是否合法从而决定是否拦截。

普通请求很容易处理,遍历资源权限数据,直接判断request.methodrequest.url是否吻合就可以了。对于带参数的
url 就不能用全文匹配了,而应该用模式匹配,这里需要前后端先协商一致。

后端返回的资源权限数据中,需要将 url
的参数用通配符代替,前端的请求拦截器中也要将带参数 url
处理成跟后端一致的格式,这样才能正确校验这类
url,例如以下这两种常见的参数格式及其代替写法:

格式的匹配和参数替换可以用正则表达式实现,可能遇到的一个问题是,如果你要发起一个
url 为 “/aaa/bbb” 的请求,默认会匹配为上述第一种格式,然后被处理成
“/aaa/**” 进行权限校验。

如果这里的 “bbb” 并不是参数而是 url 的一部分,那么你可以将 url 改成
“/aaa/bbb/“,在最后加一个 “/“ 以绕过格式匹配。

如果你的项目还需要其他的通配符格式,只需要在拦截器中实现对应的匹配和转化方法就可以了。

整体思路

会话开始之初,先初始化一个只有登录路由的Vue实例,在根组件created钩子里将路由定向到登录页,用户登录成功后前端拿到用户token,设置axios实例统一为请求headers添加{"Authorization":token}实现用户鉴权,然后获取当前用户的权限数据,主要包括路由权限和资源权限,之后动态添加路由,生成菜单,实现权限指令和全局权限验证方法,并为axios实例添加请求拦截器,至此完成权限控制初始化。动态加载路由后,路由组件将随之加载并渲染,而后展现前端界面。

为解决浏览器刷新路由重置的问题,拿到token后要将其保存到sessionStorage,根组件的created钩子负责检查本地是否已有token,如果有则无需登录直接用该token获取权限并初始化,如果token有效且当前路由有权访问,将加载路由组件并正确展现;若当前路由无权访问将按路由设置跳转404;如果token失效,后端应返回4xx状态码,前端统一为axios实例添加错误拦截器,遇到4xx状态码执行退出操作,清除sessionStorage数据并跳转到登录页,让用户重新登录。

测试账号:

1. username: root
   password: 任意
2. username: client
   password: 任意

  

如何用 Vue 实现前端权限控制,vue权限控制

本文来自作者 雅X共赏 在 GitChat 上分享 「如何用 Vue
实现前端权限控制(路由权限 + 视图权限 +
请求权限)」,「阅读原文」查看交流实录。

「文末高能」

编辑 | 哈比

最小依赖原则

Vue-Access-Control的定位是单一领域解决方案,除了Vue/Vue-Router/axios之外没有其他依赖,理论上可以无障碍的应用到任何有权限控制需求的Vue项目中,项目基于webpack 模板开发构建,大多数新项目可以直接基于检出代码继续开发。需要说明的是,项目额外引入的Element-UI和CryptoJS仅用于开发演示界面,他们不是必须且与权限控制毫无关系,项目应用中可以自行取舍。

最小依赖原则

Vue-Access-Control的定位是单一领域解决方案,除了Vue/Vue-Router/axios之外没有其他依赖,理论上可以无障碍的应用到任何有权限控制需求的Vue项目中,项目基于webpack 模板开发构建,大多数新项目可以直接基于检出代码继续开发。需要说明的是,项目额外引入的Element-UI和CryptoJS仅用于开发演示界面,他们不是必须且与权限控制毫无关系,项目应用中可以自行取舍。

前端权限控制具体指什么

前端权限归根结底是请求的发起权,请求的发起可能由页面加载触发,也可能由页面上的按钮点击触发。

总的来说,所有的请求发起都触发自前端路由或视图,所以我们可以从这两方面入手,对触发权限的源头进行控制,最终要实现的目标是:

  1. 路由方面,用户登录后只能看到自己有权访问的导航菜单,也只能访问自己有权访问的路由地址,否则将跳转
    4xx 提示页;

  2. 视图方面,用户只能看到自己有权浏览的内容和有权操作的控件;

  3. 最后再加上请求控制作为最后一道防线,路由可能配置失误,按钮可能忘了加权限,这种时候请求控制可以用来兜底,越权请求将在前端被拦截。

全局验证方法

验证方法的的实现本身很简单,无非是根据后端给出的资源权限做判断,重点在于优化方法的输入输出,提升易用性,经过实践总结最终使用的方案是,将权限跟请求同时维护,验证方法接收请求对象数组为参数,返回是否具有权限的布尔值。

请求对象格式:

//获取账户列表
const request = {
  p: ['get,/accounts'],
  r: params => {
    return instance.get(`/accounts`, {params})
  }
}

权限验证方法$_has()的调用格式:

v-if="$_has([request])"

  

权限验证方法的具体实现见App.vueVue.prototype.$_has方法。

将权限验证方法全局混入,就可以在项目中很容易的配合v-if实现元素显示控制,这种方式的优点在于灵活,除了可以校验权限外,还可以在判断表达式中加入运行时状态做更多样性的判断,而且可以充分利用v-if响应数据变化的特点,实现动态视图控制。

具体实现细节参考基于Vue实现后台系统权限控制中的相关章节。

请求控制

请求控制是利用axios拦截器实现的,目的是将越权请求在前端拦截掉,原理是在请求拦截器中判断本次请求是否符合用户权限,以决定是否拦截。

普通请求的判断很容易,遍历后端返回的的资源权限格式,直接判断request.methodrequest.url是否吻合就可以了,对于带参数的url需要使用通配符,这里需要根据项目需求前后端协商一致,约定好通配符格式后,拦截器中要先将带参数的url处理成约定格式,再判断权限,方案中已经实现了以下两种通配符格式:

1. 格式:/resources/:id
   示例:/resources/1
   url: /resources/**
   解释:一个名词后跟一个参数,参数通常表示名词的id

2. 格式:/store/:id/member
   示例:/store/1/member
   url:/store/*/member
   解释:两个名词之间夹带一个参数,参数通常表示第一个名词的id

  

对于第一种格式需要注意的是,如果你要发起一个url为"/aaa/bbb"的请求,默认会被处理成"/aaa/**"进行权限校验,如果这里的”bbb”并不是参数而是url的一部分,那么你需要将url改成"/aaa/bbb/",在最后加一个”/“表示该url不需要转化格式。

拦截器的具体实现见App.vue中的setInterceptor()方法。

如果你的项目还需要其他的通配符格式,只需要在拦截器中实现对应的检测和转化方法就可以了。

怎么做前端权限控制

控制的第一步是知道用户拥有哪些权限,所以用户登录后第一件事是获取权限数据。

权限数据至少应该包括路由权限和资源权限。

路由权限顾名思义,就是用户可访问的路由集合,以此作为设置前端路由和生成导航菜单的依据;资源权限是用户可访问的资源集合,“资源”
概念来自 RESTful 架构,如果对 “资源”
感到陌生也可以简单理解成用户能够发起的所有请求集合,以此作为视图控制和请求拦截的依据。

这里插入讲一下 “角色”
这个概念,可能有的系统会通过角色来做权限控制,我理解的角色就是特定几个资源打包后的快捷方式。

比如拥有总经理这个角色意味着拥有 a,b,c 这三个资源,副总经理就只有 b,c
两个资源,为用户赋予角色的本质是为用户赋予角色背后的资源。

引入角色这个概念的好处是,后台可以通过赋角色的方式,很方便的为某一类用户赋予特定的资源集合,而角色的作用应该仅限于此,尤其不应该将角色用做前端权限控制的依据,因为角色背后的资源权限是后端动态可配的。

我们也可以创建一个名字叫做 “总经理”
的角色,但其实一个资源都没有,所以前端应该始终关注资源权限本身,而只将角色视为用户的一个普通属性就好了。

有了权限数据下一步就是分别-实现对路由、视图、请求的控制。

路由控制首先要实现动态菜单,这样就可以对常规访问方式进行限制;对于非常规访问方式比如手动修改
url,可以从前端路由处着手做控制。

路由控制的思路有两种,一种是初始化即挂载全部路由,每次路由跳转前做校验;另一种是只挂载用户拥有的路由,相当于从源头上做了控制。

前者的缺点很明显,每次路由跳转都要做一遍校验是对计算资源的浪费,另外对于用户无权访问的路由,理论上就不应该挂载。

后者解决了上述问题,但仔细想这里存在一个悖论,要按需挂载路由就需要知道用户的路由权限,要知道用户的路由权限就需要用户先登录进来,但路由没有加载应用也没有初始化,用户从哪儿登录?

这里又可以有两种解决思路,一种是单独做一个登录页,登录后带着用户凭据跳转到前端应用;另一种是先初始化一个只有登录路由的应用,用户登录后动态添加路由,当然这需要框架提供支持。

视图控制需要实现一个可以在视图层调用的权限验证方法,输入用户期望的权限,输出是否拥有该权限,将调用这个方法的结果,作为界面上需要验证权限的控件或元素显示与否的依据。

请求控制实际上就是为你使用的 HTTP
库实现一个请求拦截器,对将要发起的请求与用户资源权限进行匹配,拦截越权请求。

这里值得一提的是对于携带参数的
url,需要先进行模式约定,比如/people/1这个 url
可以在权限中描述为/people/**,那么拦截器中就要先将这种 url
处理成约定后的格式,然后再进行权限验证。

数据格式约定

  • 路由权限数据必须是如下格式的对象数组,idparent_id相同的两个路由具有上下级关系,如果希望使用自定义格式的路由数据,需要修改路由控制的相关实现,详见路由控制数据格式约定

  • [

        {
          "id": "1",
          "name": "菜单1",
          "parent_id": null,
          "route": "route1"
        },
        {
          "id": "2",
          "name": "菜单1-1",
          "parent_id": "1",
          "route": "route2"
        }
      ]  
    
  • 资源权限数据必须是如下格式的对象数组,每个对象代表一个RESTful请求,支持带参数的url,具体格式说明见请求控制

     [
        {
          "id": "2c9180895e172348015e1740805d000d",
          "name": "账号-获取",
          "url": "/accounts",
          "method": "GET"
        },
        {
          "id": "2c9180895e172348015e1740c30f000e",
          "name": "账号-删除",
          "url": "/account/**",
          "method": "DELETE"
        }
    ]
    

     

自定义指令

v-if的响应特性是把双刃剑,因为判断表达式在运行过程中会频繁触发,但实际上在一个用户会话周期内其权限并不会发生变化,因此如果只需要校验权限的话,用v-if会产生大量不必要的运算,这种情况只需在视图载入时校验一次即可,可以通过自定义指令实现:

//权限指令
Vue.directive('has', {
  bind: function(el, binding) {
    if (!Vue.prototype.$_has(binding.value)) {
      el.parentNode.removeChild(el);
    }
  }
});

  

自定义指令内部仍然是调用全局验证方法,但优点在于只会在元素初始化时执行一次,多数情况下都应该使用自定义指令实现视图控制。

概述

到目前为止我们谈的都是脱离具体技术栈的实现思路,理论上可以用任何技术栈实现这个思路,但我在项目中用的是
Vue,所以下面介绍的实现细节全部基于 Vue。

先来看整个流程:

从第一步 “初始化 Vue 实例” 到 “获取权限数据”
之间做的其实是用户鉴权,这一步跟权限控制关系不大,怎么做都可以。

这里的做法是用户登录后获得一个 token,然后在请求 Headers 中设置
“Authorization”。token 会存进 sessionStorage 里,用户刷新将直接使用本地
token 授权,并重新获取权限数据,如果本地 token 失效,那么后端应该返回
401 状态码,前端跳回登陆界面。

从 “获取权限数据” 到 “异步加载路由组件”
之间做的是用户权限初始化,分别用addRoutes()方法实现动态路由及菜单,实现全局权限验证方法及指令,以及实现
axios 请求拦截。

因为用的是动态路由方案,当动态路由注入时异步路由组件会开始加载,首次访问通常是加载首页组件,如果是用户刷新,地址栏还保留着之前浏览的的
url,那么动态路由注入后也会正确的加载对应的路由组件,显示对应的界面。

下面我们着重来看权限初始化部分的实现细节,因为所有的初始化操作都基于后端给的权限数据,所以我们先来约定权限数据的数据格式:

路由权限数据是如下格式的对象数组

资源权限数据是如下格式的对象数组

路由控制

动态路由

最初实例化的路由里仅包含登录和 404
之类的基本路径,而我们期待完整的路由是这样的:

一级路由只增加了一个首页,以及最后兜底的
404,其他功能模块都作为首页的子路由,这么做主要是为了可以在首页实现全局导航菜单,实际项目中也可以调整这个路由结构。

下一步我们关注的重点应该是获取首页的子路由们,思路是事先在本地存一份整个项目的完整路由数据,根据用户的路由权限对完整路由进行筛选。

具体说一下筛选的实现,先将路由权限数据处理成如下结构:

然后遍历本地完整路由,在循环中将路径拼接成上述结构中的 key
格式,通过hashMenus[route]判断路由是否匹配。

如果你有更好的筛选方法,或者后端返回的路由权限数据与约定不同,也可以酌情修改这部分的逻辑,只要最终能得到可用的路由数据就可以。

注意在调用addRoutes()方法时,404
页面的模糊匹配一定要放在数组的最后,否则其后的路由都不会生效。

动态菜单

用户的实际路由数据可以直接用来生成导航菜单,但首先有一个小问题,路由数据是在根组件中得到的,而导航菜单存在于首页组件中,我们需要用某种方式将菜单数据传递到首页。

方法有很多,考虑到菜单数据在整个用户会话过程中不会发生改变,而且除了生成菜单之外就没有其他共享价值了,所以这里就用了最简单直接的办法,把菜单数据挂在根组件上,在首页里用this.$parent.menuData获取。

另外,导航菜单很可能会有一些个性化需求,比如添加栏目图标,这可以通过在路由中添加meta数据实现,例如将图标
class 或 unicode 存到路由 meta 里,模板中就可以访问到 meta
数据,用来生成图标标签,类似的需求也都可以这样来做。

另一个问题可能在多角色系统中比较常遇到,就是当不同角色都有一个名字相同但功能不同的路由,会发生路由名称冲突。

举例来说, 系统管理员和企业管理员都有一个叫做 “ 账号管理 “
的路由,但他们的操作对象不同,实际上这就是两个完全不同的路由,所以路由的
name 肯定要有所区分。

为了能在前端导航菜单上都能显示 “ 账号管理 “
这个名字,我们可以为路由再起一个别名,放进meta.name,生成导航菜单时优先展示别名就可以了。

动态菜单

路由数据可以直接用来生成导航菜单,但路由数据是在根组件中得到的,导航菜单存在于index.vue组件中,显然我们需要通过某种方式共享菜单数据,方法有很多,一般来说首先想到的是Vuex,但菜单数据在整个用户会话过程中不会发生改变,这并不是Vuex的最佳使用场景,而且为了尽量减少不必要的依赖,这里用了最简单直接的方法,把菜单数据挂在根组件data.menuData上,在首页里用this.$parent.menuData获取。

另外,导航菜单很可能会有添加栏目图标的需求,这可以通过在路由中添加meta数据实现,例如将图标class或unicode存到路由meta里,模板中就可以访问到meta数据,用来生成图标标签。

在多角色系统中可能遇到的一个问题是,不同角色有一个名字相同但功能不同的路由,比如说系统管理员企业管理员都有”账号管理”这个路由,但他们的操作权限和目标不同,实际上是两个完全不同的界面,而Vue不允许多个路由同名,因此路由的name必须做区分,但把区分后的name显示在前端菜单上会很不美观,为了让不同角色可以享有同一个菜单名称,我们只要将这两个路由的meta.name都设置成”账号管理”,在模板循环时优先使用meta.name就可以了。

菜单的具体实现可以参考views/index.vue

视图控制

视图控制的目标是根据当前用户权限决定界面元素显示与否,典型场景是对各种操作按钮的显示控制。实现视图控制的本质是实现一个权限验证方法,输入请求权限,输出是否获准。然后配合v-ifjsx或自定义指令就能灵活实现各种视图控制。