V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
推荐关注
Meteor
JSLint - a JavaScript code quality tool
jsFiddle
D3.js
WebStorm
推荐书目
JavaScript 权威指南第 5 版
Closure: The Definitive Guide
ruoxie
V2EX  ›  JavaScript

vue 基于 d2-admin 的 RBAC 权限管理解决方案

  •  
  •   ruoxie · 2019-01-07 22:44:39 +08:00 · 1618 次点击
    这是一个创建于 2153 天前的主题,其中的信息可能已经有所发展或是发生改变。

    前两篇关于 vue 权限路由文章的填坑,说了一堆理论,是时候操作一波了。

    vue 权限路由实现方式总结

    vue 权限路由实现方式总结二

    选择d2-admin是因为 element-ui 的相关开源项目里,d2-admin 的结构和代码是让我感到最舒服的,而且基于 d2-admin 实现 RBAC 权限管理也很方便,对 d2-admin 没有大的侵入性的改动。

    预览地址

    Github

    相关概念

    不了解 RBAC,可以看这里企业管理系统前后端分离架构设计 系列一 权限模型篇

    权限模型

    • 实现了 RBAC 模型权限控制
    • 菜单与路由独立管理,完全由后端返回
    • user存储用户
    • admin标识用户是否为系统管理员
    • role存储角色信息
    • roleUser存储用户与角色的关联关系
    • menu存储菜单信息,类型分为菜单功能,一个菜单下可以有多个功能,菜单类型的permission字段标识访问这个菜单需要的功能权限,功能类型的permission字段相当于此功能的别称,所以菜单类型的permission字段为其某个功能类型子节点的permission
    • permission存储角色与功能的关联关系
    • interface存储接口信息
    • functionInterface存储功能与接口关联关系,通过查找用户所属角色,再查找相关角色所具备的功能权限,再通过相关功能就可以查出用户所能访问的接口
    • route存储前端路由信息,通过permission字段过滤出用户所能访问的路由

    运行流程及相关 API

    使用d2admin的原有登录逻辑,全局路由守卫中判断是否已经拉取权限信息,获取后标识为已获取。

    const token = util.cookies.get('token')
        if (token && token !== 'undefined') {
          //拉取权限信息
          if (!isFetchPermissionInfo) {
            await fetchPermissionInfo();
            isFetchPermissionInfo = true;
            next(to.path, true)
          } else {
            next()
          }
        } else {
          // 将当前预计打开的页面完整地址临时存储 登录后继续跳转
          // 这个 cookie(redirect) 会在登录后自动删除
          util.cookies.set('redirect', to.fullPath)
          // 没有登录的时候跳转到登录界面
          next({
            name: 'login'
          })
        }
    
    //标记是否已经拉取权限信息
    let isFetchPermissionInfo = false
    
    let fetchPermissionInfo = async () => {
      //处理动态添加的路由
      const formatRoutes = function (routes) {
        routes.forEach(route => {
          route.component = routerMapComponents[route.component]
          if (route.children) {
            formatRoutes(route.children)
          }
        })
      }
      try {
        let userPermissionInfo = await userService.getUserPermissionInfo()
        permissionMenu = userPermissionInfo.accessMenus
        permissionRouter = userPermissionInfo.accessRoutes
        permission.functions = userPermissionInfo.userPermissions
        permission.roles = userPermissionInfo.userRoles
        permission.interfaces = util.formatInterfaces(userPermissionInfo.accessInterfaces)
        permission.isAdmin = userPermissionInfo.isAdmin == 1
      } catch (ex) {
        console.log(ex)
      }
      formatRoutes(permissionRouter)
      let allMenuAside = [...menuAside, ...permissionMenu]
      let allMenuHeader = [...menuHeader, ...permissionMenu]
      //动态添加路由
      router.addRoutes(permissionRouter);
      // 处理路由 得到每一级的路由设置
      store.commit('d2admin/page/init', [...frameInRoutes, ...permissionRouter])
      // 设置顶栏菜单
      store.commit('d2admin/menu/headerSet', allMenuHeader)
      // 设置侧边栏菜单
      store.commit('d2admin/menu/fullAsideSet', allMenuAside)
      // 初始化菜单搜索功能
      store.commit('d2admin/search/init', allMenuHeader)
      // 设置权限信息
      store.commit('d2admin/permission/set', permission)
      // 加载上次退出时的多页列表
      store.dispatch('d2admin/page/openedLoad')
      await Promise.resolve()
    }
    

    后端需要返回的权限信息包括权限过滤后的角色编码集合,功能编码集合,接口信息集合,菜单列表,路由列表,以及是否系统管理员标识。格式如下

    {
      "statusCode": 200,
      "msg": "",
      "data": {
        "userName": "MenuManager",
        "userRoles": [
          "R_MENUADMIN"
        ],
        "userPermissions": [
          "p_menu_view",
          "p_menu_edit",
          "p_menu_menu"
        ],
        "accessMenus": [
          {
            "title": "系统",
            "path": "/system",
            "icon": "cogs",
            "children": [
              {
                "title": "系统设置",
                "icon": "cogs",
                "children": [
                  {
                    "title": "菜单管理",
                    "path": "/system/menu",
                    "icon": "th-list"
                  }
                ]
              },
              {
                "title": "组织架构",
                "icon": "pie-chart",
                "children": [
                  {
                    "title": "部门管理",
                    "icon": "html5"
                  },
                  {
                    "title": "职位管理",
                    "icon": "opencart"
                  }
                ]
              }
            ]
          }
        ],
        "accessRoutes": [
          {
            "name": "System",
            "path": "/system",
            "component": "layoutHeaderAside",
            "componentPath": "layout/header-aside/layout",
            "meta": {
              "title": "系统设置",
              "cache": true
            },
            "children": [
              {
                "name": "MenuPage",
                "path": "/system/menu",
                "component": "menu",
                "componentPath": "pages/sys/menu/index",
                "meta": {
                  "title": "菜单管理",
                  "cache": true
                }
              },
              {
                "name": "RoutePage",
                "path": "/system/route",
                "component": "route",
                "componentPath": "pages/sys/route/index",
                "meta": {
                  "title": "路由管理",
                  "cache": true
                }
              },
              {
                "name": "RolePage",
                "path": "/system/role",
                "component": "role",
                "componentPath": "pages/sys/role/index",
                "meta": {
                  "title": "角色管理",
                  "cache": true
                }
              },
              {
                "name": "UserPage",
                "path": "/system/user",
                "component": "user",
                "componentPath": "pages/sys/user/index",
                "meta": {
                  "title": "用户管理",
                  "cache": true
                }
              },
              {
                "name": "InterfacePage",
                "path": "/system/interface",
                "component": "interface",
                "meta": {
                  "title": "接口管理"
                }
              }
            ]
          }
        ],
        "accessInterfaces": [
          {
            "path": "/menu/:id",
            "method": "get"
          },
          {
            "path": "/menu",
            "method": "get"
          },
          {
            "path": "/menu/save",
            "method": "post"
          },
          {
            "path": "/interface/paged",
            "method": "get"
          }
        ],
        "isAdmin": 0,
        "avatarUrl": "https://api.adorable.io/avatars/85/[email protected]"
      }
    }
    

    设置菜单

    将固定菜单(/menu/header/menu/aside)与后端返回的权限菜单(accessMenus)合并后,存入相应的 vuex store 模块中

    ...
    let allMenuAside = [...menuAside, ...permissionMenu]
    let allMenuHeader = [...menuHeader, ...permissionMenu]
    ...
    // 设置顶栏菜单
    store.commit('d2admin/menu/headerSet', allMenuHeader)
    // 设置侧边栏菜单
    store.commit('d2admin/menu/fullAsideSet', allMenuAside)
    // 初始化菜单搜索功能
    store.commit('d2admin/search/init', allMenuHeader)
    

    处理路由

    默认使用routerMapComponents 的方式处理后端返回的权限路由

    //处理动态添加的路由
    const formatRoutes = function (routes) {
        routes.forEach(route => {
            route.component = routerMapComponents[route.component]
            if (route.children) {
            formatRoutes(route.children)
            }
        })
    }
    ...
    formatRoutes(permissionRouter)
    //动态添加路由
    router.addRoutes(permissionRouter);
    // 处理路由 得到每一级的路由设置
    store.commit('d2admin/page/init', [...frameInRoutes, ...permissionRouter])
    

    路由处理方式及区别可看vue 权限路由实现方式总结二

    设置权限信息

    将角色编码集合,功能编码集合,接口信息集合,以及是否系统管理员标识存入相应的 vuex store 模块中

    ...
    permission.functions = userPermissionInfo.userPermissions
    permission.roles = userPermissionInfo.userRoles
    permission.interfaces = util.formatInterfaces(userPermissionInfo.accessInterfaces)
    permission.isAdmin = userPermissionInfo.isAdmin == 1
    ...
    // 设置权限信息
    store.commit('d2admin/permission/set', permission)
    

    接口权限控制以及 loading 配置

    支持使用角色编码,功能编码以及接口权限进行控制,如下

    export function getMenuList() {
        return request({
            url: '/menu',
            method: 'get',
            interfaceCheck: true,
            permission:["p_menu_view"],
            loading: {
                type: 'loading',
                options: {
                    fullscreen: true,
                    lock: true,
                    text: '加载中...',
                    spinner: 'el-icon-loading',
                    background: 'rgba(0, 0, 0, 0.8)'
                }
            },
            success: {
                type: 'message',
                options: {
                    message: '加载菜单成功',
                    type: 'success'
                }
            }
        })
    }
    

    interfaceCheck: true表示使用接口权限进行控制,如果 vuex store 中存储的接口信息与当前要请求的接口想匹配,则可发起请求,否则请求将被拦截。

    permission:["p_menu_view"]表示使用角色编码和功能编码进行权限校验,如果 vuex store 中存储的角色编码或功能编码与当前表示的编码相匹配,则可发起请求,否则请求将被拦截。

    源码位置在libs/permission.js,可根据自己需求进行修改

    loading配置相关源码在libs/loading.js,根据自己需求进行配置,success也是如此,源码在libs/loading.js。 照此思路可以自行配置其它功能,比如请求失败等。

    页面元素权限控制

    使用指令v-permission

     <el-button
        v-permission:function.all="['p_menu_edit']"
        type="primary"
        icon="el-icon-edit"
        size="mini"
        @click="batchEdit"
        >批量编辑</el-button>
    

    参数可为functionrole,表明以功能编码或角色编码进行校验,为空则使用两者进行校验。

    修饰符all,表示必须全部匹配指令值中所有的编码。

    源码位置在plugin/permission/index.js,根据自己实际需求进行修改。

    使用v-if+全局方法:

    <el-button
        v-if="canAdd"
        type="primary"
        icon="el-icon-circle-plus-outline"
        size="mini"
        @click="add"
        >添加</el-button>
    
    data() {
        return {
          canAdd: this.hasPermissions(["p_menu_edit"])
        };
      },
    

    默认同时使用角色编码与功能编码进行校验,有一项匹配即可。

    类似的方法还要hasFunctionshasRoles

    源码位置在plugin/permission/index.js,根据自己实际需求进行修改。

    不要使用v-if="hasPermissions(['p_menu_edit'])"这种方式,会导致方法多次执行

    也可以直接在组件中从 vuex store 读取权限信息进行校验。

    开发建议

    • 页面级别的组件放到pages/目录下,并且在routerMapCompnonents/index.js中以 key-value 的形式导出

    • 不需要权限控制的固定菜单放到menu/aside.jsmenu/header.js

    • 不需要权限控制的路由放到router/routes.js frameIn

    • 需要权限控制的菜单与路由通过界面的管理功能进行添加,确保菜单的path与路由的path相对应,路由的name与页面组件的name一致才能使keep-alive生效,路由的componentrouterMapCompnonents/index.js中能通过 key 匹配到。

    • 开发阶段菜单与路由的添加可由开发人员自行维护,并维护一份清单,上线后将清单交给相关的人去维护即可。

    如果觉得麻烦,不想菜单与路由由后端返回,可以在前端维护一份菜单和路由(路由中的component还是使用字符串,参考mock/permissionMenuAndRouter.js),并且在菜单和路由上面维护相应的权限编码,一般都是使用功能编码。后端就不需要返回菜单和路由信息了,但是其他权限信息,比如角色编码,功能编码等还是需要的。通过后端返回的功能编码列表,在前端过滤出用户具备权限的菜单和路由,过滤处理后后的菜单与路由格式与之前由后端返回的格式一致,然后将处理后的菜单与路由当做后端返回的一样处理即可。

    数据 mock 与代码生成

    数据 mock 使用lazy-mock修改而来的d2-admin-server,数据真实来源于后端,相比其他工具,支持数据持久化,存储使用的是 json 文件,不需要安装数据库。简单的配置即可自动生成增删改查的接口。

    后端使用中间件控制访问权限,比如:

     .get('/menu', PermissionCheck(), controllers.menu.getMenuList)
    

    PermissionCheck默认使用接口进行校验,校验用户所能访问的 API 中是否匹配当前 API,支持使用功能编码与角色编码进行校验PermissionCheck(["p_menu_edit"],["r_menu_admin"],true),第一个参数为功能编码,第二个为角色编码,第三个为是否使用接口进行校验。

    更多详细用法可看lazy-mock 文档

    前端代码生成还在开发中...

    1 条回复    2019-01-08 00:08:55 +08:00
    sunorg
        1
    sunorg  
       2019-01-08 00:08:55 +08:00 via Android
    好复杂。

    可以参考 yii2 的 rbac,3 或 4 表完美解决所有。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   943 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 24ms · UTC 22:36 · PVG 06:36 · LAX 14:36 · JFK 17:36
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.