浅谈 RBAC 权限系统设计

发布时间 2023-11-17 17:09:19作者: 柯基与佩奇

方案设计

在实际业务中,权限系统的设计其实可以做到很复杂,但是为了简单起见只保留一些最基本且核心的模块:

  • 登录模块:权限平台一般需要靠登录获取用户身份,并通过凭证去请求接口,包括注册功能。
  • 系统管理模块:包括用户管理、角色管理、菜单管理(如果菜单是前端控制则可以省略)等功能,是权限系统中的核心部分。

而权限控制其实分为了两部分:数据权限功能权限 控制。主要从四个方面入手:

  • 接口权限:权限系统的最后一道保护屏障,每个权限接口都需要 token
  • 路由权限:通过拦截器阻止越权页面访问,防止用户直接通过 URL 进入页面。
  • 菜单权限:通过控制菜单权限来给不同角色的用户展示不同菜单。
  • 按钮权限:控制数据的操作,没有权限时隐藏或者按钮置灰禁用。

image.png

可以看到权限无非就是对用户的操作和视图控制,一般需要前后端人共同去配合去做,后端小伙伴更像是守门员,守住最后一道防线,而前端小伙伴则负责在球进门之前给他阻挡掉,从而减少守门员的压力。

菜单路由权限方案

有一个很重要的问题那就是,菜单和路由要怎么管理呢?有的人说放到前端管理,有的人说放到后端管理,看看两种常见菜单路由方案(下面以 Vue 生态为例)。

方案一:前端管理菜单路由

这种方案主要以 vue-element-admin 这个开源项目为代表的方案:

  • 设置两种路由:公共路由权限路由。同时在路由表的 meta 字段中绑定菜单相关信息。
  • vue 实例化时前创建静态路由,用户登录后根据角色信息筛选出动态路由.
  • vue 实例化后通过使用 vue-routeraddRoutes 将动态路由添加到路由表。
  • 根据角色信息过滤路由表生成菜单。

这种方式的优点是不依赖后端,可单独管理,也不需要实现菜单管理这个单独的功能。但也有些缺点:

  • 菜单路由耦合,需要在路由里面配置菜单信息,而且路由不一定是菜单,却多了菜单的配置。
  • 如果需要改菜单的配置比如图标、文案、排序需要前端编码并重新编译部署。

具体代码实现和细节可以看花裤衩大佬的 这篇文章

方案二:前后端配合管理路由菜单

方案一每次都需要前端改动代码修改菜单配置,于是针对这个点可以把菜单路由交给后端管理,比较成熟的代表是 若依后台管理系统 ,它的整体方案和 vue-element-admin 差不多,区别在于菜单和路由是前后端一起管理的。

router/index.js

import Vue from "vue";
import Router from "vue-router";

Vue.use(Router);

/* Layout */
import Layout from "@/layout";

/**
 * Note: 路由配置项
 *
 * hidden: true                     // 当设置 true 的时候该路由不会再侧边栏出现 如401,login等页面,或者如一些编辑页面/edit/1
 * alwaysShow: true                 // 当一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式--如组件页面
 *                                  // 只有一个时,会将那个子路由当做根路由显示在侧边栏--如引导页面
 *                                  // 若想不管路由下面的 children 声明的个数都显示根路由
 *                                  // 可以设置 alwaysShow: true,这样它就会忽略之前定义的规则,一直显示根路由
 * redirect: noRedirect             // 当设置 noRedirect 的时候该路由在面包屑导航中不可被点击
 * name:'router-name'               // 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
 * query: '{"id": 1, "name": "ry"}' // 访问路由的默认传递参数
 * roles: ['admin', 'common']       // 访问路由的角色权限
 * permissions: ['a:a:a', 'b:b:b']  // 访问路由的菜单权限
 * meta : {
    noCache: true                   // 如果设置为true,则不会被 <keep-alive> 缓存(默认 false)
    title: 'title'                  // 设置该路由在侧边栏和面包屑中展示的名字
    icon: 'svg-name'                // 设置该路由的图标,对应路径src/assets/icons/svg
    breadcrumb: false               // 如果设置为false,则不会在breadcrumb面包屑中显示
    activeMenu: '/system/user'      // 当路由设置了该属性,则会高亮相对应的侧边栏。
  }
 */

// 公共路由
export const constantRoutes = [
  {
    path: "/login",
    component: () => import("@/views/login"),
    hidden: true,
  },
  {
    path: "/register",
    component: () => import("@/views/register"),
    hidden: true,
  },
  {
    path: "/404",
    component: () => import("@/views/error/404"),
    hidden: true,
  },
  {
    path: "/401",
    component: () => import("@/views/error/401"),
    hidden: true,
  },
];

// 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [
  {
    path: "/system/user-auth",
    component: Layout,
    hidden: true,
    permissions: ["system:user:edit"],
    children: [
      {
        path: "role/:userId(\\d+)",
        component: () => import("@/views/system/user/authRole"),
        name: "AuthRole",
        meta: { title: "分配角色", activeMenu: "/system/user" },
      },
    ],
  },
];

// 防止连续点击多次路由报错
let routerPush = Router.prototype.push;
Router.prototype.push = function push(location) {
  return routerPush.call(this, location).catch((err) => err);
};

export default new Router({
  mode: "history", // 去掉url中的#
  scrollBehavior: () => ({ y: 0 }),
  routes: constantRoutes,
});

核心拦截器实现和 vue-element-admin 差不多:

permission.js

import router from "./router";
import store from "./store";
import { Message } from "element-ui";
import NProgress from "nprogress";
import "nprogress/nprogress.css";
import { getToken } from "@/utils/auth";
import { isRelogin } from "@/utils/request";

NProgress.configure({ showSpinner: false });

const whiteList = ["/login", "/auth-redirect", "/bind", "/register"];

router.beforeEach((to, from, next) => {
  NProgress.start();
  if (getToken()) {
    to.meta.title && store.dispatch("settings/setTitle", to.meta.title);
    /* has token*/
    if (to.path === "/login") {
      next({ path: "/" });
      NProgress.done();
    } else {
      if (store.getters.roles.length === 0) {
        isRelogin.show = true;
        // 判断当前用户是否已拉取完user_info信息
        store
          .dispatch("GetInfo")
          .then(() => {
            isRelogin.show = false;
            store.dispatch("GenerateRoutes").then((accessRoutes) => {
              // 根据roles权限生成可访问的路由表
              router.addRoutes(accessRoutes); // 动态添加可访问路由表
              next({ ...to, replace: true }); // hack方法 确保addRoutes已完成
            });
          })
          .catch((err) => {
            store.dispatch("LogOut").then(() => {
              Message.error(err);
              next({ path: "/" });
            });
          });
      } else {
        next();
      }
    }
  } else {
    // 没有token
    if (whiteList.indexOf(to.path) !== -1) {
      // 在免登录白名单,直接进入
      next();
    } else {
      next(`/login?redirect=${to.fullPath}`); // 否则全部重定向到登录页
      NProgress.done();
    }
  }
});

router.afterEach(() => {
  NProgress.done();
});

为了方便理解这个逻辑给画个图:

image.png

用户登录后会请求两个接口数据:

  • 用户信息,包括当前用户角色信息、权限信息,核心结构如下:
{
  "msg": "操作成功",
  "code": 200,
  "permissions": [
    "system:user:resetPwd",
    "system:post:list",
    "monitor:operlog:export",
    "monitor:druid:list"
  ],
  "roles": ["common"],
  "user": {}
}
  • 菜单路由信息,当前用户拥有的路由,核心结构如下:
{
  "msg": "操作成功",
  "code": 200,
  "data": [
    {
      "name": "System",
      "path": "/system",
      "hidden": false,
      "redirect": "noRedirect",
      "component": "Layout",
      "alwaysShow": true,
      "meta": {
        "title": "系统管理",
        "icon": "system",
        "noCache": false,
        "link": null
      },
      "children": [
        {
          "name": "Log",
          "path": "log",
          "hidden": false,
          "redirect": "noRedirect",
          "component": "ParentView",
          "alwaysShow": true,
          "meta": {
            "title": "日志管理",
            "icon": "log",
            "noCache": false,
            "link": null
          },
          "children": [
            {
              "name": "Operlog",
              "path": "operlog",
              "hidden": false,
              "component": "monitor/operlog/index",
              "meta": {
                "title": "操作日志",
                "icon": "form",
                "noCache": false,
                "link": null
              }
            }
          ]
        }
      ]
    }
  ]
}

有了这些数据前端需要再对数据做一层数据,把路由表给弄出来,都知道路由组件在前端一般都是这样的:

{
  name: "login",
  path: "/login",
  component: () => import("@/views/Login.vue")
}

后端是不能直接返回这样的路由的,因为前端代码都是编译后的,已经不认识 @/views/Login.vue 了,所以需要前端进行处理:

export const loadView = (view) => {
  if (process.env.NODE_ENV === "development") {
    return (resolve) => require([`@/views/${view}`], resolve);
  } else {
    // 使用 import 实现生产环境的路由懒加载
    return () => import(`@/views/${view}`);
  }
};

篇幅有限,有兴趣可以直接看若依的源码实现:菜单处理逻辑传送门。下图就是若依的效果截图:

image.png

方案二的优点很明显那就是可以不用部署的方式去修改菜单信息,非常方便,但是也有问题:

  • 菜单路由还是耦合
  • 需要单独开发一个菜单管理功能,前后端的工作量会多出一块
  • 不能随便乱改菜单数据,否则影响整个系统

当然这两个方案可能都不适用,比如还可能存在菜单必须全部展示的情况,不能用的要置灰提示(提示用户充钱才可以用),还有其他什么方案可以在评论区进行讨论的哦!只能说不管是软件开发还是方案选型都没有银弹这一说,适合业务的才是好的!

按钮权限方案

按钮权限一般在前端实现,按钮最终下发的操作是对数据的操作所以本质还是接口权限,后端需要兜底。而前端的场景无非就两种:

  • 无权限时按钮置灰禁用
  • 无权限时按钮直接隐藏

按钮权限稍微麻烦的点在于根据什么去判断权限,角色标识符还是权限点?

这点在# 浅谈 RBAC 权限模型 有提到过基于资源的权限控制,所以基于权限点去做控制比较好,若依也是这样判断的,但是也要补充下根据权限点判断的几种情况:

  • 按钮操作依赖多个权限点时:满足其一或者全部才可以操作,否则就隐藏或者禁用置灰。
  • 按钮操作依赖单个权限点时:满足单个权限点才可以操作,否则就隐藏或者禁用置灰。

基于上述条件可以设计一个权限指令 v-auth

场景一:传入单个权限点:

<button v-auth="'system:user:add'">test</button>

场景二:传入多个权限点:

<!-- 必须满足全部权限点 -->
<button v-auth="['system:user:add', 'system:user:edit']">test</button>

<!-- 满足其中一个权限点 -->
<button v-auth.oneOf="['system:user:add', 'system:user:edit']">test</button>

指令的实现思路就是判断传入的值与全局的权限点进行对比,满足条件后对 DOM 进行操作(移除、置灰...),下面是若依的实现:

import store from "@/store";

export default {
  inserted(el, binding, vnode) {
    const { value } = binding;
    const all_permission = "*:*:*";
    const permissions = store.getters && store.getters.permissions;

    if (value && value instanceof Array && value.length > 0) {
      const permissionFlag = value;

      const hasPermissions = permissions.some((permission) => {
        return (
          all_permission === permission || permissionFlag.includes(permission)
        );
      });

      if (!hasPermissions) {
        el.parentNode && el.parentNode.removeChild(el);
      }
    } else {
      throw new Error(`请设置操作权限标签值`);
    }
  },
};

要实现 oneOf 的功能只需要对修饰符判断即可,这里就不额外说了,有兴趣可以自己实现。