浅谈前端角色权限方案

1,555 阅读16分钟

FinClip 前端工程狮如何实现不同角色与权限的控制及落地,从而控制不同的用户能够访问不同的页面呢?

前言

对于大部分管理后台而言,角色权限都是一个重要的环节。通过角色权限的配置,我们可以轻松的调整用户访问能否访问相关的页面。

通俗易懂的来说,就是哪些页面是向所有用户开放的,哪些是需要登录后才能访问的,哪些是要拥有xx角色权限才能访问的等等(这里的xx指的是管理员这些的角色)。

在后台管理系统中角色权限的方案设计是很重要的。如果设计的不够好那么有可能会导致项目中各种权限代码混入业务代码造成结构混乱,后期的维护包括给新增的模块添加控制也将变得十分麻烦。

虽然前端在权限层面能做一些事情,但很遗憾真正对权限进行把关的是后端。 在一个软件系统中,前端所调用的接口是不应该被无权调用通过并且返回数据的。因此接口这块后端必须严格根据权限去控制。简而言之前端在不写一行权限代码的情况下,当用户进入某个他无权访问的页面时,后端是可以判断他越权访问并拒绝返回数据的。但这样应用的体验很不好,比如访问无权限页面时各种报错问题等等。因此前端在角色权限中更多职责的应是完善用户的交互体验。另外一个很重要的原因就是前端做的权限校验都是可以被本地数据造假越权通过。

角色权限控制的整个流程中,前端整个流程步骤应是登录或刷新时接受后台发送的权限数据,然后将数据注入到应用中,整个应用于是开始对页面的展现内容以及导航逻辑进行控制,从而达到权限控制的目的。前端做的权限控制虽然能提供一层防护,但根本目的还是为了优化体验。

本文将从下面三个方面,讲述前端角色权限的实现

  • 登录权限控制
  • 页面权限控制
  • 内容权限控制

登录权限控制

登录权限控制,简而言之就是实现哪些页面能被未登录的用户访问,哪些页面只有用户登录后才能被访问。

实现这个功能也很简单,下面例举出 2 种常见的实现方案。

   export const routes = [
      {
         path: '/login', //登录页面
         name: 'Login',
         component: Login,
      },
      {
         path:"/register", // 列表页
         name:"Register",
         component: Register, 
      },
      {
         path:"/list", // 列表页
         name:"List",
         meta:{
            need_login:true //需要登录
         }
      }
    ]

现有三个页面:登录页、注册页和列表页。登录页和注册页所有人都可以访问,但列表页面需要登录后才能看到,给该路由添加一个meta对象,并将need_login置为true

在代码层面,通过router.beforeEach可以轻松实现上述目标,每次页面跳转时都会调用router.beforeEach包裹的函数,代码如下:

router.beforeEach((to, from, next) => {
  const { need_login = false } = to.meta;
  const { user_info } = store.state; //从vuex中获取用户的登录信息
  if (need_login && !user_info) {
    // 如果页面需要登录但用户没有登录跳到登录页面
    const next_page = to.name; // 配置路由时,每一条路由都要给name赋值
    next({
      name: 'Login',
      params: {
        redirect_page: next_page,
        ...from.params, //如果跳转需要携带参数就把参数也传递过去
      },
    });
  } else {
    //不需要登录直接放行
    next();
  }
});

to是要即将访问的路由信息,从其中拿到need_login的值可以判断是否需要登录。再从vuex中拿到用户的登录信息。

如果用户没有登录并且要访问的页面又需要登录时就使用next跳转到登录页面,并将需要访问的页面路由名称通过redirect_page传递过去,在登录页面就可以拿到redirect_page等登录成功后直接跳转。 如若不想添加meta,可看下面另一种方式。

  // 引入无需登录的页面
  import invisible from './invisible';

  let router = new Router({
    routes: [
      ...invisible,
    ],
  });

  const invisibleMap = [];
  invisible.forEach(item => {
    if (item.name) {
      invisibleMap.push(item.name);
    }
  });

  router.beforeEach(async (to, from, next) => {
    if (!invisibleMap.includes(to.name)) {
        // 业务逻辑判断登录等
    }
    else {
      next();
    }
  })

页面权限控制

页面权限控制主要是解决给不同角色赋予不同的页面访问权限,接下来先了解一下角色的概念。

在一些权限设置比较简单的系统里,使用上面第一种方法就足够了,但如果系统引入了角色,那么就要在上面基础上,再进一步改造增强权限控制的能力。

角色的出现是为了更加个性化配置权限列表,比如当前系统设置三个角色:普通成员、管理员以及超级管理员。普通成员能够浏览软件系统的 a、b、c 三个模块,但是它不能查看和编辑 d、e 模块。管理员拥有普通会员的所有能力,另外它还能查看 d、e 模块。超级管理员拥有软件系统所有权限,他单独拥有赋予某个账号为管理员或移除其身份的能力。

一旦软件系统引入了角色的概念,那么每个账户应当都至少对应着一个或几个角色,从而拥有一个或几个角色所相应的权限。前端要做的事情就是依据账户所拥有的角色身份从而给与它相应页面访问和操作的权限。

普通成员,管理员以及超级管理员这样角色的安排还是一种非常简单的划分方式,在实际项目中,角色的划份要更加细致的多。比如一些常见的后台业务系统,软件系统会按照公司的各个部门来建立角色,诸如市场部,销售部,研发部之类。公司的每个成员就会被划分到相应角色中,从而具备该角色所拥有的权限。

上面介绍那么多角色的概念,也主要是为了从全栈的维度去理解权限的设计,但真正落地到前端项目中是不需要去处理角色逻辑的,那部分功能主要由后端完成。

现在假定后端不处理角色完全交给前端来做会出现什么问题。 首先前端新建一个配置文件,假定当前系统设定三种角色:普通成员,管理员以及超级管理员以及每个角色能访问的页面列表。

 export const permission_list = {
   member:["List","Detail"], //普通成员
   admin:["List","Detail","Manage"],  // 管理员
   super_admin:["List","Detail","Manage","Admin"]  // 超级管理员
 }

数组里每个值对应着前端路由配置的 name 值,普通会员能访问「列表页」和「详情页」,管理员能额外访问「管理页」,超级管理员能额外访问「超级管理页 」。

整个运作流程简述如下,当用户登录成功之后,通过接口返回值得知用户数据和所属角色。拿到角色值后就去配置文件里取出该角色能访问的页面列表数组,随后将这部分权限数据加载到应用中从而达到权限控制的目的。

从上面流程看,角色放在前端配置也是可以的。但假如项目已经上线,产品经理要求项目急需增加一个新角色如 x 客户,并把原来已经存在的用户刘某移动到 x 客户 下面。那这样的变动会导致前端需要修改代码文件,在原来的配置文件上再新建角色来满足这一需求。

由此可见由前端来配置角色列表是非常不灵活且容易出错的,那么最优方案是交给后端去配置。用户一旦登录后,后端接口直接返回该账号拥有的权限列表就行了,至于该账户属于什么角色以及角色拥有的页面权限合理方案应是后端处理。

以下介绍第二种角色权限的方案。 如后端返回的账户信息结构如下

 {
  user_id:1,
  user_name:"刘某",
  permission_list:["List","Detail","Manage"]
}

前端此时是不用在意改账户拥有哪些角色的,只需要把他拥有权限的页面给予展示就行了。

从这可以看出该账户拥有「列表、详情、管理」三个页面的权限。

    //静态路由
    export const invisible = [
      {
         path: '/login', //登录页面
         name: 'Login',
         component: Login,
      },
      {
         path:"/", // 首页
         name:"Home",
         component: Home, 
      }
    ]
    
    //动态路由
    export const dynamic_routes = [
       {
           path:"/list", // 列表页
           name:"List",
           component: List
       },
       {
           path:"/detail", // 详情页
           name:"Detail",
           component: Detail
       },
       {
           path:"/manage", // 管理页
           name:"Manage",
           component: Manage
       },
       {
           path:"/admin", // 超级管理页
           name:"Admin",
           component: Admin
       }
    ]

现在将所有路由分成两部分,静态路由routes和动态路由dynamic_routes。静态路由里面的页面是所有角色都能访问的,它里面主要区分登录访问和非登录访问,处理的逻辑与上面介绍的一致。 动态路由里面存放的是与角色定制化相关的的页面。现在继续看下面张三的接口数据,该如何给他设置权限。

import store from "@/store";

export const invisible = [...]; //静态路由

export const dynamic_routes = [...]; //动态路由

const router = createRouter({ //创建路由对象
  history: createWebHashHistory(),
  routes,
});

//动态添加路由
if(store.state.user != null){ //从vuex中拿到用户信息
    //用户已经登录
    const { permission_list } = store.state.user; // 从用户信息中获取权限列表
    const allow_routes = dynamic_routes.filter((route)=>{ //过滤允许访问的路由
      return permission_list.includes(route.name); 
    })
    allow_routes.forEach((route)=>{ // 将允许访问的路由动态添加到路由栈中
      router.addRoute(route);
    })
}

export default router;

我们先从vuex里面拿到当前用户的权限列表,然后遍历动态路由数组dynamic_routes,从里面过滤出允许访问的路由,最后将这些路由动态添加到路由实例里。

这样就实现了用户只能按照他对应的权限列表里的规则访问到相应的页面,至于那些他无权访问的页面,路由实例根本没有添加相应的路由信息,因此即使用户在浏览器强行输入路径越权访问也是访问不到的。

动态添加路由这部分代码最好单独封装起来,因为用户登录和刷新页面时都需要调用。

这种方式需要维护 dynamic_routes,当每次新增动态路由页面时,dynamic_routes 数组都需要新增,并且还需要保持和后端 permission_list 返回的数组里面的 name一致(若不一致则需要建立一个名称映射表)。

此外,这种方法对于没有权限的路由来说,页面是被添加到 router 里面去的,当访问时则需要调转到 404 默认页面。

添加嵌套子路由

假如静态路由的形式如下,现在想把列表页添加到 Tabs 嵌套路由的 children 里面。

  const routes = [
  {
    path: '/',  //标签容器
    name: 'Tabs',
    component: Tabs,
    children: [{
       path: '', //首页
       name: 'Home',
       component: Home,
    }]
  }
]

export const dynamic_routes = [
  {
      path:"/list", // 列表页
      name:"List",
      component: List
  }
]

官方 router.addRoute 给出了相应的配置去满足这样的需求(代码如下)。router.addRoute 接受两个参数,第一个参数对应父路由的 name 属性,第二个参数是要添加的子路由信息。

   router.addRoute("Tabs", {
        path: "/list",
        name: "List",
        component: List,
 });

上述方法是一个一个给动态路由新增,对于页面较多,页面嵌套较深(一级页面、二级页面、三级页面等)的项目也可以参考下面的方式。

后端返回如下:

 {
   "home": {
     "id":"100",
     "name":"home",
     "desc":"首页",
     "value":true,
     "children": [],
   }
 }

通过判断 value 来决定这个页面是否有权展示,children 下为当前页面或者说模块下的二级页面,三级页面等,结构跟 home 应是一样的。

此时前端需要做的是递归遍历后端返回的这个结构,当判断到 false 的时候,把对应到的路由页面给过滤掉。

// 生成过滤路由和菜单的方法  
function filterRouter(arr, obj, type) {
  if (Array.isArray(obj)) {
    // 数组处理
    obj.forEach(item => {
      handleRouterItem(arr, item, type);
    });
  } else {
    // 对象处理
    for (let item in obj) {
      handleRouterItem(arr, obj[item], type);
    }
  }
}

// 处理每个元素节点
function handleRouterItem(arr, item, type) {
  // 确定这个页面或模块是不展示的
  if (item.value === false) {
    if (type === 'menu') {
      assistance(arr, routerMap[item.name]);
    } else {
      assistanceRouter(arr, routerMap[item.name]);
    }
  } else if (!item.isPage && item.childrens && item.childrens.length > 0) {
    filterRouter(arr, item.childrens, type);
  }
}

function assistanceRouter(arr, name, obj) {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].name === name) {
      // 无权限页面设置meta字段或者直接删除
      Vue.prototype.$set(arr[i].meta, 'hasRoleAuth', false);
      return true;
    } else {
      if (arr[i].children && arr[i].children.length > 0) {
        if (assistanceRouter(arr[i].children, name, arr[i])) {
          return;
        }
      }
    }
  }
}

// router为所有页面的路由结构,roleRouter为后端返回的角色权限对象
filterRouter(router, roleRouter);

这种方式是将通过递归遍历后端的权限字段,从而将已有的路由结构给过滤一遍。

切换用户

切换用户信息是非常常见的功能,但是应用在切换成不同账号后可能会引发一些问题,例如用户先使用超级管理员登录,由于超级管理员能访问所有页面,因此所有页面路由信息都会被添加到路由实例里。

此时该用户退出账号,使用一个普通成员的账号登录。在不刷新浏览器的情况下,路由实例里面仍然存放了所有页面的路由信息,即使当前账号只是一个普通成员,如果他越权访问相关页面,路由还是会跳转的,这样的结果并不是我们想要的。

解决方案有两种。

第一种是用户每次切换账户后刷新浏览器重新加载,刷新后的路由实例是重新配置的所以可以避免这个问题,但是刷新页面会带来不好的体验。

第二种方案是当用户选择登出后,清除掉路由实例里面处存放的路由栈信息(代码如下)。

  const router = useRouter(); // 获取路由实例
  const logOut = () => { //登出函数
      //将整个路由栈清空
      const old_routes = router.getRoutes();//获取所有路由信息
      old_routes.forEach((item) => {
        const name = item.name;//获取路由名词
        router.removeRoute(name); //移除路由
      });
      //生成新的路由栈
      routes.forEach((route) => {
        router.addRoute(route);
      });
      router.push({ name: "Login" }); //跳转到登录页面
    };

内容权限控制

页面权限控制它能做到让不同角色访问不同的页面,但对于一些颗粒度更小的项目,比如希望不同的角色都能进入页面,但要求看到的页面内容不一样,这就需要对内容进行权限控制了

假设某个后台业务系统拥有增删改查几个功能。项目需求该系统存在三个角色:职员、领导和高层领导。职员不具备修改、删除以及发布的功能,他只能查看列表。当职员进入该页面时,页面上只显示列表内容。其他三个相关功能的按钮移除(或弹窗提示)。

领导角色保留列表和发布按钮。高级领导角色保留页面上所有内容。

我们拿到图片后要先要对页面内容整体分析一遍,按照增删查改四个维度对页面内容进行归类。使用简称 CURD 来标识(CURD 分别代表创建(Create)、更新(Update)、读取(Retrieve)和删除(Delete))。

上图中列表内容属于查询操作,因此设定为 R。凡是具备 R 权限的用户就显示该列表内容。

发布需求属于新增操作,设定凡是具备 C 权限的用户就显示该按钮。

同理修改按钮对应着 U 权限,删除按钮对应着 D 权限。

由此可以推断出职员角色在该页面的权限编码为 R,它只能查看列表内容无法操作。

领导角色对应的权限编码为 CR。高级领导对应的权限编码为 CURD。

现在用户登录完成后,假设后端接口返回的数据如下(将这条数据存到 vuex ):

 {
  user_id:1,
  user_name:"张三",
  permission_list:{
    "List":"CR", //权限编码
    "Detail":"CURD"  //权限编码
  }
}

张三除了静态路由设置的页面外,他只能额外访问 List 列表页以及 Detail 详情页。其中列表页他只具备创建和新增权限,详情页他具备增删查改所有权限。那么当张三访问上图中的页面时,页面中应该只显示列表和发布需求按钮。

我们现在要做的就是设计一个方案尽可能让页面内容方便被权限编码控制。首先创建一个全局的自定义指令 permission,代码如下:

import router from './router';
import store from './store';

const app = createApp(App); //创建vue的根实例

app.directive('permission', {
  mounted(el, binding, vnode) {
    const permission = binding.value; // 获取权限值
    const page_name = router.currentRoute.value.name; // 获取当前路由名称
    const have_permissions = store.state.permission_list[page_name] || ''; // 当前用户拥有的权限
    if (!have_permissions.includes(permission)) {
      el.parentElement.removeChild(el); //不拥有该权限移除dom元素
    }
  },
});

当元素挂载完毕后,通过 binding.value 获取该元素要求的权限编码。然后拿到当前路由名称,通过路由名称可以在 vuex 中获取到该用户在该页面所拥有的权限编码,如果该用户不具备访问该元素的权限,就把元素 dom 移除。 对应到上面的案例,在页面里按照如下方式使用 v-permission 指令。

<template>
    <div>
      <button v-permission="'U'">修改</button>  <button v-permission="'D'">删除</button>
    </div>
    <p>
      <button v-permission="'C'">发布需求</button>
    </p>

    <!--列表页-->
    <div v-permission="'R'">
     ...
    </div>
</template>

将上面模板代码和自定义指令结合理解一下就很容易明白整个内容权限控制的逻辑。首先前端开发页面时要将页面分析一遍,把每一块内容按照权限编码分类。比如修改按钮就属于 U,删除按钮属于 D。并用 v-permission 将分析结果填写上去。

当页面加载后,页面上定义的所有 v-permission 指令就会运行起来。在自定义指令内部,它会从 vuex 中取出该用户所拥有的权限编码,再与该元素所设定的编码结合起来判端是否拥有显示权限,权限不具备就移除元素。

虽然分析过程有点复杂,但是以后每个新页面想接入权限控制非常方便。只需要将新页面的各个 dom 元素添加一个 v-permission 和权限编码就完成了,剩下的工作都交给自定义指令内部去做。

对于特殊的业务场景,如隐藏后导致样式混乱、UI 设计不协调等。此时则应具体根据项目内的需求去判断是否隐藏还是弹出提示无权限等,在这不做过多的叙述。\

尾言

权限控制在前端更多的应为优化用户体验,除此以外也为应用加固了一层防护,但是需要注意的是前端的相关校验是可以通过技术手段破解的。然而权限问题关乎到软件系统所有数据的安危。

因此为了确保系统平稳运行,前后端都应该做好自己的权限防护。

点击 FinClip 博客了解更多有趣内容。