要做权限管理?你或许可以先看下这个

10,545 阅读21分钟

在最近几个项目中,领导都要求我们对项目做权限管理功能,因为这一个个项目的试水,我对项目的权限管理功能添加有了一些自己的想法,在我的理解里也算一步步的趋于完善了(当然可能由于视野和阅历等的不足,在大佬眼里会有很多不足,欢迎指点不足0^0)


妹子镇楼,吸引火力

我的理解

我认为权限管理其实主要做的就是:前端先实现一个拥有完整功能的项目,然后对需要做控制的模块外层包一个访问的判断条件,而这个判断条件就是该功能的权限控制参数,权限控制就是对这个控制参数进行可控制处理。

初次操作

最开始做权限管理的项目其实想的没那么深,实现也是只是实现到页面级别的权限上,而且多角色平台互不影响,并且功能独立,所以做起来还是比较容易的,在这个项目里面并没有专门的权限控制模块,而是通过后端读取一个静态配置文件然后将页面权限信息通过一个接口的方式返回到前端,前端在登录成功的返回值以及获取个人信息接口中传递这个权限列表参数(ps.为什么要在这两个接口中返回这个参数呢?是因为....懒啊!懒得再重新开个接口,登陆成功后获取到权限列表就会将这个信息存储在vuex中,在每次页面刷新的时候都会有请求个人信息的接口并对vuex中的权限信息进行更新,所以没必要单独开接口请求)

然后项目分为管理端和用户端,虽然代码都写在同一个项目中,但是两个端使用的是不同的登录入口,成功后跳转页面也是不一样的,每个端都有自己的首页,而且这个首页的权限是不需要控制,一直都要有的,根据这样的需求一看下来发现就很简单,具体就是:

  1. 与后端约定需要控制页面的权限列表permissionList(我跟后端开发者约定的格式是object形式的,key值是页面router的name,value是个Boolean值,这样进行权限判断的时候取值方便)
  2. 在每个可访问页面的路由定义的时候添加meta属性,并设置requireAuth: true(同时也可以传一些其他你自己需要的配置信息,可以在页面内访问到的哦)
{
  path: '/home',
  name: 'home',
  component: home,
  meta: {
    requireAuth: true
  }
}
  1. 侧边菜单栏(或者在顶部,反之就是菜单)前端写好,每一个叶子结点肯定都对应的有一个点击跳转的页面,所以在渲染菜单的时候要对每个菜单节点判断对应的页面是否有权限,如果没有该叶子结点就不展示
  2. 在router的钩子函数beforeEach中添加监听,因为每次页面触发路由变化的时候都会触发该钩子函数,然后判断要前往(参数to)的页面是否需要判断权限即meta中的requireAuth字段,如果需要,那么就从vuex中读取权限列表,然后判断需要前往的页面是否有权限,如果没有就跳转无权限页面(如果项目没有这个页面的话就不进行跳转),反之就正常页面切换
router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAuth)) {
    // 表示要前往的页面需要权限
    // 添加自己的判断条件
    if (xxx) {
      next({path: '/xxx'})
    } else {
      // 跳转无权限页面
      next({path: '/forbidden'})
    }
  } else {
    next() // 确保一定要调用 next()
  }
})

另外可以参考官方文档关于路由元的讲解

这样一个简单的权限管理就实现了,是不是很low?

总结一下优缺点:

优点:代码实现简单

缺点:

  1. 权限的控制粒度只能到页面级别
  2. 前后端并没有完全分离,在开发中还需要人工向后端同步新添加的权限页面name
  3. 有临时需求维护起来不方便,需要手动修改权限配置文件

哈哈,这种方式虽然不够“优雅”,但贵在简单,如果需求比较简单的其实用这种方式就可以的,当然如果你想要对页面级别的权限控制的更加优雅的话推荐看一下我朋友的这篇如何优雅的在 vue 中添加权限控制

再次操刀

后面再次有一个项目需要做权限控制,这一次领导加需求了,要求权限控制到按钮级别,因为是小项目(同样没有独立的权限管理模块),没给多长时间,所以我也没往深的想,简单想了一下觉得按钮级别权限不是简单嘛,只需要在第一种方式上面扩展一下就行了呗(当时也是陷入了死胡同没多想)

我修改了这些地方:

  1. permissionList的每一个字段仍然是对应一个router的页面,但是value不再是Boolean类型了,而是扩展成Object类型了,比如一个用户列表页面UserList拥有添加按钮、删除按钮、编辑按钮、查看详情按钮四个功能,那么在权限列表中的表现形式就是:
permissionList = {
  UserList: {
    show: true, // 页面查看权限(默认拥有获取列表数据权限)
    add: true, // 创建用户权限
    edit: true, // 编辑用户权限
    delete: true, // 删除用户权限
    detail: true, // 查看用户详情权限
  },
  pageB: {},
  ......
}
  1. 在router.beforeEach中的判断就需要去取show字段来识别是否拥有要前往的页面的权限
  2. 当进入目标页面后,页面初始化的时候要去permissionList.UserList中取相对应的功能按钮的权限,比如创建用户按钮是否渲染就需要根据permissionList.UserList.add是否是true来确定该按钮是否渲染

变动不多,也很容易理解,主要原理就是对页面上所有的可见可操作按钮都进行定义,然后在渲染的时候获取权限信息,对按钮加以控制,但有一个问题就是这样控制的太独立了,举个例子,一个账号对用户列表页面有访问权限,同时页面的创建按钮同样有权限,但是点击用户创建页面没有访问权限,这时候其实就比较尴尬了,因为明明展示有创建按钮但是点击却又跳转无权限页面,交互上很不友好

总结一下优缺点:

优点:哈哈哈,还是代码实现简单。。还有各页面权限独立互不干扰

缺点:可维护性不是很高,配置复杂需要好好思考,要考虑各权限之间的关系,很容易出现有按钮但是没有页面权限的情况

终章

lei了lei了,重头戏来了,顺便贴一下我在GitHub上面写的一个简易的权限管理demo,配合文章查看更美味(这个项目是我在上个table组件文章的demo基础上面添加的权限管理,添加权限的时候愈发觉得组件封装的便利)

首先这个项目拥有自己的独立权限模块,在这个模块你可以创建所有你想创建的权限,其次这个项目虽然分为三个大的模块,但是功能并不是跟角色强相关的,并不是一个平台对应一个角色,或者说并没有严格的角色这个概念,当平台需要创建一个新的账号的时候(平台主要靠卖账号提供服务赚钱),你需要做这些行为

  1. 预置好平台所有可控制的权限
  2. 创建角色(严格说不是角色,而是一个被分配了权限的...我也不知道用什么形容好,还是叫角色吧)
  3. 给“角色”分配权限(一个“角色”可以拥有多个平台的权限)
  4. 创建账号
  5. 给账号分配角色(一个账号可以拥有多个“角色”)
  6. 卖账号!

分配权限,分配角色没有什么难点,所以这个项目的难点就在于权限的控制上面了,如何做好权限的灵活控制是降低后期维护成本的关键要素,那么一步步来:

1. 平台划分、路由定义

根据需求该项目是有三个平台,大致对应学员端,教师管理端,总管理端,根据上面的需求,一个账号可能拥有多个平台的访问、操作权限,但每个平台的界面风格又有所差异,不可能一股脑根据权限判断塞在一个页面展示中,在展示上三平台是“分开”的,所以会有一个进行模块切换的功能

模块的访问权限我是将其独立出来单独作为一项单独权限来设置,这样在进行权限分配的时候如果要临时对一个账号去除某个平台权限的话就非常简单,只需要将该平台权限置为false就可以了,但是其实这样设计并不好(在这里这么搞就是单纯的为了偷个懒,大家不要学),一个模块是否有权限访问应该由他是否有该模块下内容的权限来决定,而不是由单独的权限来控制,权限的设计也不应该有严格的父子关系,这样在后续项目如果有模块变动就不会引起权限逻辑崩塌(别问我怎么知道的,你知道这个项目设计之初是划分的两平台吗?说多了都是泪啊...)

这里我插一句,我个人建议进行路由跳转的时候尽量使用命名路由,别问为什么,也是泪...不然后面项目突然baseUrl要变,所有模块内的路由跳转都要改,所以你懂吧0^0

说道这我就再插一句,有人要问单页面跳转用name跳转还好,直接this.$router.push({ name: 'balabala' }),但是如果某个需求是新开窗口打开页面呢?vue-router又不支持,只能用window.open('/aa/bb', '_blank')这样的怎么搞?告诉你个小技巧:这里就要用到router.resolve了,你可以这么写:

let routerUrl = this.$router.resolve({ name: 'balabala' })
window.open(routerUrl.href, '_blank')

回到正题,在路由层面,抛开公共页面(404,500,login等页面),另外就是三个模块路由了,定义路由的base分别是admin,manage,student,然后各个模块、页面的路由分别按照设计划在这三个base的children内进行定义,然后我制定了一个路由命名规范,即所有路由name使用下划线分隔的命名方式平台名称_模块_内容,与菜单项直接对应的页面即模块的初始入口页面就直接是平台名称_模块,要求其他前端开发人员在进行路由name命名的时候一定要按照这个规范来进行命名,不然实现菜单高亮的时候就会出现问题,这个后面会讲到

2. 菜单and导航的实现

由于学员端功能较少,设计并没有给其设计侧边菜单,而是将功能集中在了导航栏上面,而另外两个管理端功能较多也复杂,导航栏放不下所以使用的是侧边菜单,项目使用的是element-ui所以导航栏以及侧边菜单都是使用的el-menu,并在el-menu进行了定制的样式修改,为了方便维护以及后续添加新的栏目,我将菜单栏的渲染抽离成了对数据的渲染而不是直接将每个子菜单写死在侧边栏文件内,也就是我在项目的config目录下面新建了一个menuConfig文件,然后该文件内部定义有三个menuList,分别对应三个模块并向外抛出,我定义的大致规则是这样的:

export const adminPlatformMenu = [
  {
    name: 'admin_main', // 菜单项的index
    desc: '首页', // 菜单项展示的内容
    icon: 'balabala...' // 菜单项的图标(如果需要的话)
  }, {
    name: 'admin_account', // 这一项有子菜单,所以这项index并不需要进行页面定向,其实可以随意写,但是是必填项
    desc: '账号管理',
    icon: 'balabala...',
    children: [
      {
        name: 'admin_user',
        desc: '用户管理'
      }, {
        name: 'admin_department',
        desc: '部门管理'
      }, {
        name: 'admin_role',
        desc: '角色管理'
      }, {
        name: 'admin_auth',
        desc: '权限管理'
      }
    ]
  }, {
    name: 'admin_resource', // 通样这项可以随意写,但是必填
    desc: '资源管理',
    icon: 'balabala...',
    children: [
      {
        name: 'admin_tool',
        desc: '工具库管理'
      }, {
        name: 'admin_knowledge',
        desc: '知识库管理',
        children: [
          {
            name: 'admin_loophole', // 三级菜单
            desc: '漏洞库管理'
          }, {
            name: 'admin_plan',
            desc: '应急预案库管理'
          }
        ]
      }
    ]
  }
]

然后在入口文件内引用,通过循环遍历数据生成想要的菜单树(这个就不用我来了吧),这里有几个点要注意:

首先,在menuConfig文件中获取到的是完整的menuList,但是我们渲染可不是要全部渲染出来,所以需要先获取权限列表permissionList,然后通过一个遍历方法筛选出有权限的menuList,我的筛选方法是这么写的:

// 接收两个入参,分别是菜单列表和权限列表
export const toFilterHavePermissionMenu = function (menuList = [], authList= {}) {
  let newMenu = []
  menuList.forEach(item => {
    if (item.hasOwnProperty('children')) {
      let returnArr = toFilterHavePermissionMenu(item.children, authList)
      returnArr.length > 0 && newMenu.push({ ...item, children: returnArr })
    } else authList[item.name] && newMenu.push(item)
  })
  return newMenu
}

其次就是菜单项根据权限显示隐藏问题,举个例子:有一个菜单叫综合管理,然后综合管理下面又分有公告管理、账号管理、日志管理等子菜单,账号管理菜单下又分有部门管理、用户管理、角色管理、权限管理等子菜单,就这样一个三级菜单,针对综合管理和账号管理这种菜单,我是否要给他创建一个权限来控制呢?我上面说过的,一个模块是否有权限访问应该由他是否有该模块下内容的权限来决定,而不是由单独的权限来控制,所以在循环遍历有权限的menuList生成菜单树的时候只需要判断拥有访问权限的子菜单个数即children.length是否为0即可。

然后因为el-menu必须要有index这个属性,所以index的定义一定要跟点击当前菜单要跳转的页面的route.name对应起来,然后菜单不要使用router模式,因为我们的index使用的不是route.path而是route.name,使用router模式的话会找不到页面的,这里我在点击的回调里面自己定义了跳转方法

@select="handleSelect"

handleSelect (key, keyPath) {
  this.$router.push({ name: key })
}

再然后就是defaultActive也就是菜单高亮的设置了
由于defaultActive取的是当前菜单的index,所以现在明白我要求index必须跟route.name对应原因了吧,因为菜单激活的回调方法的返回值就是index,所以在回调中我们就可以直接跳转到index,而不需要再去判断该怎么跳

然后编写其他非菜单跳转的目标页面的页面时,会发现一个问题,就比如,因为用户管理页面的name和导航栏的index相同,所以菜单项高亮,但是创建用户页面同样需要高亮用户管理的菜单项,但是却因为name不同而没能高亮,所以,我前面才说我对路由name的命名制定了一套规范,必须按照这套规范来进行页面的命名,为的就是处理这个问题,然后我们修改高亮的默认方法,如下:

defaultActive () {
  let currentName = this.$route.currentName
  let nameArr = currentName.split('_')
  if (nameArr.length > 2) return nameArr[0] + '_' + nameArr[1]
  else return currentName
}

菜单渲染完了之后,会发现一个问题,因为所有模块都是由权限控制,那么当默认的首页没有权限的时候,用户登录完成后跳到哪里?所以登录成功后的跳转不应该写死而是动态获取,所以我写了一个路由重定向的方法,在demo中可以看到

// 接收一个参数:经过筛选后有权限的菜单列表
export const toFindNextRedirectRouter = function (hasPermissionMenu = []) {
  if (hasPermissionMenu.length > 0) {
    for (let item of hasPermissionMenu) {
      if (item.hasOwnProperty('children')) return toFindNextRedirectRouter(item.children)
      else return item
    }
  }
}

这样就会自动重定向到第一个有权限的菜单页

3.页面以及页面内权限的划分

权限划分最直观的的页面级的权限,不论你是通过点击页面内容进行正常流程的路由跳转还是手动更改路由进行跳转,这个权限定义就很明确-->跟路由一一对应,但是这个权限应该包括哪些内容呢?只是单纯的页面访问权限吗?一个用户列表页面,如果我把获取用户列表功能做成一个单独的权限,如果访问者没有获取列表权限那岂不是进来就是一个空白的页面?

其次就是很多人想做的“按钮级”的权限,上一个案例讲述的那种方案就很僵硬,但其实

  1. 我们要做的真的是按钮的权限吗?
  2. 按钮级的权限到底是什么?展示按钮是否展示或者可否点击?亦或者是包含按钮点击后所需要执行的一系列直接关联的操作的权限?
  3. 如果要控制的是一个select筛选项的权限呢?
  4. 同样后端接口也有权限控制,如果一个页面前端拥有权限而后端接口返回了个没有权限是不是很尴尬?

一波灵魂拷问下来是不是感觉有点问题了?所以,使用“按钮级”权限并不是很合适,我认为使用功能模块级的权限来形容更合适,那么什么是“功能模块级”权限划分?

这样,我来描述这么一个页面:

  1. 菜单项拥有用户管理这个子菜单
  2. 点击用户管理子菜单跳转用户管理页面
  3. 用户管理页面主体是一个展示用户信息的分页表格,顶部有“创建用户”,“批量删除”按钮,通过部门的select筛选项,表格的每一行内有对单条用户的操作项:编辑,删除
  4. 点击创建用户按钮跳转创建用户页面,创建用户页面主体是一个表单,需要用户输入包括姓名(input)、部门(select)等信息,拥有提交按钮和取消按钮
  5. 点击提交按钮提交表单信息,成功则返回用户管理页面,点击取消直接返回用户管理页面
  6. 点击行内编辑按钮携带用户id跳转编辑页面,通过用户id获取用户信息并初始化入表单,其他同创建功能
  7. 点击行内删除按钮弹窗提示是否确认删除该用户,确定后执行删除操作,然后刷新列表
  8. 点击批量删除按钮弹窗提示是否确认批量删除用户,确定后执行批量删除操作,然后刷新列表
  9. 选择筛选部门select则执行携带筛选参数重新刷新列表

大概就这么个页面,对应后端接口有:获取用户列表接口、获取部门select下拉数据接口、根据用户id获取用户信息接口、创建用户接口、编辑用户接口、删除用户信息接口、批量删除用户信息接口(也可以跟删除用一个接口,看你们自己后端的设计)

怎么进行权限控制才会更加合理和“人性化”?

  • 点击菜单项用户管理根据是否拥有admin_user权限(看前面的权限菜单定义)跳转用户管理页面,跳转到用户管理页面后展示一片空白也不合适吧,所以应该给他调用获取用户列表接口的权限,同样的后端需要给获取用户列表接口的访问权限,这样初始化进来就不尴尬了,所以admin_user权限需要包括:页面访问权限 + 调用获取用户列表接口的权限 + 用户列表接口的访问权限(后端)
  • 列表有了数据还需要筛选,既然筛选就需要获取部门的下拉列表数据,所以筛选功能admin_user_department_filter权限需要包括:筛选框是否展示 + 调用获取部门下拉列表接口的权限 + 调用获取用户列表接口的权限(筛选完后需要刷新列表) + 获取部门下拉列表接口的访问权限(权限) + 获取用户列表接口的访问权限(后端)(ps.这个权限其实没必要独立出来,合并在admin_user是最合适的,但是为了demo就强行独立了哈哈)
  • 同理,创建用户功能的权限admin_user_add需要包括:创建按钮是否展示 + 创建页面的访问权限 + 部门下拉数据获取接口调用权限 + 部门下拉数据获取接口访问权限(后端) + 提交创建用户接口的调用权限 + 提交创建用户接口的访问权限(后端)
  • 编辑权限admin_user_edit与创建差不多就是多了个获取用户信息接口的调用权限 + 获取用户信息接口的访问权限(后端)(这种行内根据循环渲染出来的按钮怎么控制权限呢?配合我写的这个表格组件食用更佳哦,关于权限的demo也是做在这个表格之上的,后面会有demo的项目地址)
  • 删除权限admin_user_delete则应该包括:删除按钮是否展示 + 删除弹窗是否展示 + 删除用户信息接口的调用权限 + 删除用户信息接口的访问权限(后端)
  • 批量删除admin_user_multidel和删除差不多,可能会多一个是否展示复选框的功能(但是这个复选框很多时候根据后端返回列表数据控制是否展示)

这样,在与后端开发人员的约定下,我们将功能模块进行了细分,然后每个模块对应一个权限控制字段,一个权限控制字段又对应一个或者多个前端功能和后端接口,这样划分下来就清晰了很多,进行权限分配的时候也就很明确了。

模块划分完成并且定义好权限字段,那么下来就是对权限的创建和分配了,前面也说到了,权限之间不应该有层级关系,父级没有必要定义单独的权限,不然模块变动的时候本来是父子级的突然就同级了,权限管理就很容易崩掉,如果只看他有没有子权限来确定父菜单要不要展示就不会出现这种问题了,因此权限创建的时候就不能有父子关系,要把它拍平,但是这样会有个问题就是进行权限分配的时候没有层级的权限展示让人勾选分配的时候就很困难,这样就很矛盾了

因为我现在做的这个项目领导就经常进行模块变动,还是大变动,所以我这权限之间无论如何不能有父子管理,所以我就想是不是可以新创建一个模块,数据库新建一张表就是专门用来设置、存储权限之间的当前视觉层级关系的,但是这个只是用来存储数据结构然后渲染展示用,并不真的对权限做关联,数据库中权限表的权限数据之间还是没有关联关系的,这样如果权限变动,权限模块和项目不会崩,而进行权限分配的时候只是会觉得模块怎么好像跟实际项目不对应,但是并不影响权限的分配,如果需要的话只需要对定义权限“关系”的模块进行重新创建层级关系就可以了(ps.这个想法只是我想想,并没有实际操刀过,但我觉得可行性应该还可以,我目前的项目工期很紧,领导也不给我时间让我做一个新的模块,也没有后端开发人员配合,所以就夭折了,目前也是写死一个权限视觉层级关系的JSON用来渲染展示供用户使用)

各位看官可以移步前往一边对照代码一遍理解这个权限管理,因为没有后台服务,我就将权限配置放在了一个静态文件anthConfig.js中,同时也方便直接修改验证,需要修改就直接改文件的配置即可在页面生效

效果图


大概就这样了吧,这个文章的所有代码都可以在我的中找到,受工作经历所限可能很多地方考虑的不周全甚至是错的,还希望看到的大佬能多多提指正啊,我会认真修改的,就酱,告辞