浅谈前端角色权限方案
在前端中如何实现不同角色与权限的控制及落地,从而控制不同的用户能够访问不同的页面呢?
前言
对于大部分管理后台而言,角色权限都是一个重要的环节。通过角色权限的配置,我们可以轻松的调整各个用户所拥有的各个模块或者说页面的权限,从而让用户只能访问到对应权限的页面。
通俗易懂的来说,就是哪些页面是向所有用户开放的,哪些是需要登录后才能访问的,哪些是要拥有 xx 角色权限才能访问的等等(这里的 xx 指的是管理员、普通成员等这些的角色)。
在后台管理系统中角色权限的方案设计是很重要的。其一,好的设计能为后面新增的模块或者说页面省下很多功夫。其二,好的设计能为之后的拓展功能(比如权限具体控制某个按钮等)提供更多的维护和设计思路。其三,好的设计可使代码可读性更强,更能一眼区分开权限代码与业务代码。
对于角色权限而言,真正进行把关的是应该是后端。首先是因为前端的相关代码校验是可以被数据造假通过的,安全性并不高。其次,在一个系统中前端所调用的接口是不应该被无权调用通过并且返回数据的。因此接口这块后端必须严格根据权限去控制,谨防无权限直接调用得到数据返回。简而言之就是即使前端没有把控页面和权限,用户也不能够获取没有权限的页面或者模块的相关数据和操作的,后端应该是可以判断他越权访问并拒绝返回数据的。但是若无前端把控,那这样整个系统的体验将会很糟糕,比如访问无权限页面时各种报错问题等等。因此前端在角色权限中更多职责的应是完善用户的交互体验。
角色权限控制的整个流程中,前端整个流程步骤应是首先展示无需登录的默认页面(比如 404 页面、登录页、注册页),然后在登录或浏览器刷新时调用后端接口拿到后端给的该账户的权限数据,然后将数据注入到系统中,整个系统拿到权限数据后就开始对页面的展现内容以及页面导航进行生成,最终生成一个只展示当前用户所拥有对应权限的系统。从而达到整个角色权限的控制。综上所述,前端在角色权限中更多职责的应是完善用户的交互体验。
本文将从下面三个方面,讲述前端角色权限的实现
- 登录权限控制
- 角色权限控制
- 内容权限控制
登录权限控制
登录权限控制,简而言之就是实现哪些页面能被未登录的用户访问,哪些页面只有用户登录后才能被访问。
实现这个功能也很简单,下面例举出 2 种常见的实现方案。
第一种为将无需登录的页面路由放在一起,代码如下:
let invisible = [
{
path: '/login', //登录页面
name: 'Login',
component: Login,
},
{
path: '/404',
name: 'index-notFount',
component: () => import('@/pages/core/NotFount/index'),
},
];
export default invisible;
定义一个 invisible 数组,数组中包含着所有无需登录就可以查看的页面路由。
// 引入无需登录的页面
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();
}
})
引入 invisible,路由名称映射到 invisibleMap 数组上,在路由守卫中拦截判断。由此做到无需登录的页面可以直接查看(放在 invisible 数组中),需要登录的页面则会进行登录等业务判断。
除上述方法外,也可在路由对象中以添加 meta 的方式去实现登录页面权限控制,相关代码如下:
export const routes = [
{
path: '/login', //登录页面
name: 'Login',
component: Login,
},
{
path:"/list", // 列表页
name:"List",
meta:{
need_login:true //需要登录
}
}
]
代码如上所示,登录页面由于无需登录,因此可不用设置 meta.need_login 属性,然而列表页面需要登录,因此设置 need_login 属性。
同第一种方式一样,真正的拦截在路由守卫之中,代码如下:
router.beforeEach((to, from, next) => {
if (to.meta.need_login) {
// 业务逻辑判断登录等
} else {
next();
}
});
此处拿到 need_login 字段,判断是否为需要登录的路由页面,如若是,则进行下一步登录逻辑判断等,如若不是,则可放行。
角色权限控制
在讨论角色权限控制之前,我们应该先清楚一个点:在引入了角色概念的系统中,任何该系统中的账号都应该至少拥有一个或几个角色身份,这样该账号就拥有当前这一个角色(或几个角色)的相关权限功能。简而言之,我们不会直接把权限赋予给用户,而是通过角色来赋予给用户。角色权限控制主要是解决给不同角色赋予不同权限从而赋予不同账户权限,接下来先了解一下角色的概念。
在某个系统当中,存在 3 个比较普遍性的角色:普通成员、管理员以及超级管理员。普通成员能够浏览系统的 a、b、c 三个模块,但是它不能查看和编辑 d、e 模块(假设只有 d、e 模块可编辑)。管理员拥有普通会员的所有权限,另外它还能查看 d、e 模块和编辑 d 模块。超级管理员拥有此系统的所有权限,因此相比于管理员而言,在这就多出一个编辑 e 模块。
当然,就上述而言,都是一些简单的角色划分。本文不在此做更多的深讨。
那么角色权限,在设计上能否以前端为主导呢?(即后端只为账号标识为某某角色,控制角色权限这块由前端主导)
我们通过一个简单的例子来回答上述问题。
我们暂且根据以上的较为普遍的角色来做简单设计。
export const permission = {
member:["Home"], //普通成员
admin:["Home" ,"Notify"], // 管理员
super_admin:["Home" ,"Notify","Manage"] // 超级管理员
}
在上述角色权限中,普通成员拥有首页权限,管理员拥有首页、通知权限,超级管理员则还额外拥有管理的权限。
如果以前端为主导,那么后端则应是在登录接口返回当前账户所属哪些角色。拿到该账号的角色后后就去上面的配置文件里取出该角色所能访问的页面权限,随后将这部分页面权限加载到系统中从而达到权限控制的目的(需要注意的是,数组里面的值应和对应页面的路由名称相匹配)。
在上述设计中,后端只负责给账户标识对应角色,并且写入库中,在登录时返回给前端此账户对应的角色。到了这一步,可能会有同学存在疑问,这样不是也挺好的吗,前端不也可以控制角色权限嘛。别急,那现在来思考一个问题。如果对一个已上线的系统项目,现需要紧急新增一个角色比如 x,那么前端就要急需修改配置文件(配置文件如上图),此时还不够,还需把之前的 y 用户移动到 x 角色下,那么此时不光是前端要改配置文件,后端也需要在库中把 y 用户移动到 x 角色下。这样的改动显得非常容易出错且复杂。
综上所述,在角色权限这块,其实最好的办法就是交给后端去配置,有哪些角色,账户对应哪些角色这些逻辑应当是后端负责,后端通过登录直接返回该账户所拥有的权限,前端这块无需过度关注角色主要职责应是根据后端的权限返回,展示对应的权限页面和菜单。这样即使碰到上述的修改也能轻便灵巧的解决。
以下介绍角色权限的方案。
如后端返回的账户权限结构如下
{
"home": {
"id":"100",
"name":"home",
"desc":"首页",
"value":true,
"children": [],
}
}
在这个权限结构之中,id 为页面或者说模块的唯一标识 id,name 此处最好与前端路由页面对象的 name 值相对应,desc 为菜单上展示的名称,value 代表这个模块或者页面是否展示,children 数组为此页面的二级页面数组,对于路由的权限控制和菜单的渲染生成都有着重要的影响。
在此结构中,前端通过判断 value 来决定这个页面是否有权限展示,children 下为当前页面或者说模块下的二级页面,三级页面等,结构跟 home 应是一样的。如若一级页面value为false,那下面的二级、三级应当都无权展示。
此时前端需要做的是递归遍历后端返回的这个结构,当判断 value 为 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.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字段或者直接删除
// arr.splice(i, 1);
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;
}
}
}
}
}
function assistance(arr, name, obj) {
for (let i = 0; i < arr.length; i++) {
if (arr[i].name === name) {
arr.splice(i, 1);
return true;
} else {
if (arr[i].children && arr[i].children.length > 0) {
if (assistance(arr[i].children, name, arr[i])) {
return;
}
}
}
}
}
export const rolePermission = () => {
// router为所有页面的路由结构,roleRouter为后端返回的角色权限对象
filterRouter(router, roleRouter);
router.addRoutes(router);
}
在上述代码中,router 为前端的路由对象数组,roleRouter 为后端返回的该账号所拥有的角色权限的相应的数据结构。
在 filterRouter 函数中,遍历 roleRouter 数据结构中的每一项,把每一项的处理逻辑交给 handleRouterItem。
在 handleRouterItem 函数中,判断每一项的 value 字段是否为 false,如若为 false,则说明这个模块或者说页面是没有权限展示的。那么则应该交给 assistanceRouter 和 assistance 过滤掉该模块或者页面。
在 assistanceRouter 和 assistance 函数中,它俩的主要作用则是在数组路由对象中找到 name 值和参数 name 一致的路由对象,在 assistanceRouter 函数中则是在 meta 对象中用 hasRoleAuth 字段做以标记,代表无权访问用以路由权限也可以和assistance 函数一样做过滤处理。在 assistance 中则是把无权限的页面过滤用于菜单生成。
以上的这种方式是通过递归遍历后端的权限字段,将已有的路由结构给过滤一遍,从而生成对应权限的路由结构和菜单的一种方式。
这样就实现了用户只能按照他对应的权限列表里的权限规则访问及菜单看到相应的页面。
动态添加路由 rolePermission 这部分代码最好单独封装起来,因为用户登录和刷新页面时都需要调用。
退出及切换用户
在引入角色权限的系统之中,退出及切换用户也是相当重要的。因为不同账号的权限往往不同。因此要格外注意退出及切换账号的时候不能带着上一个账户的权限信息,不然会引发严重的漏洞。
那么针对角色权限的退出及注销我们可以采取哪些解决方案呢。
解决方案有两种。
第一种方案是用户在退出或切换账户后刷新浏览器,但是这种方案会给用户带来不那么友好的体验。
第二种方案则是当用户退出后,初始化相关路由实例,代码如下:
import Router from 'vue-router';
import router from '@/router';
import store from '@/store/index.js';
import invisible from '@/router/invisible';
export const resetRouter = () => {
let newRouter = new Router({
routes: [...invisible],
});
router.matcher = newRouter.matcher;
store.commit('CLEAR_ROLE_AUTH');
};
初始化当前账户的动态路由,并且将 vuex 中的当前角色的权限信息也一并给清除掉。
内容权限控制
在上一 part 的角色权限中,它做到了让不同账户访问不同的页面,但是往往有时候需要更细腻的去控制页面中的某个元素,如增删改一一对应了一个按钮,这个时候,就需要针对页面的内容,做出内容权限控制。
在本文中,就简单的以增删改作为内容权限控制内容。
沟通后,现在后端的返回结构应该是这样:
{
"home": {
"id":"100",
"name":"home",
"desc":"首页",
"value":true,
"children": [],
"options": {
"create": true,
"delete": true,
"update": true,
}
}
}
在当前这个结构中,home 首页存在三个内容权限控制,分别为创建、删除、更新(如需新增,可在与后端沟通好字段后加在options内)。
在拿到这样的数据结构后,我们还需设计一个方案,让这个权限结构和页面内容相关联起来,在这,我们用到了指令。我们创建一个全局的自定义指令 permission,伪代码如下:
import router from '@/router';
import store from '@/store';
app.directive('permission', {
mounted(el, binding, vnode) {
const permission = binding.value; // 获取指令值
const current_page = router.currentRoute.value.name; // 获取当前路由名称
const options = getOptions(current_page) // getOptions方法为拿到路由名称对应的角色权限对象
if (!options[permission]) {
el.parentElement.removeChild(el); // 没有该内容权限
}
},
});
在上述代码中,首先拿到指令值,再获取到当前路由名称,通过 getOptions 方法拿到该路由名称对应的角色权限数据结构中的相关对象,进而判断 options 内是否有该内容权限,如若没有,则将该 dom 移除。
在 html 中,指令的用法如下:
<template>
<div>
<button v-permission="'create'">创建</button>
<button v-permission="'update'">修改</button>
<button v-permission="'delete'">删除</button>
</div>
</template>
看到这,相信大家都已经明白了内容权限控制的流程和逻辑。简而言之,就是将角色权限的相关数据结构与 dom 相关通过指令绑定起来。
对于特殊的业务场景,如隐藏后导致样式混乱、UI 设计不协调等。此时则应具体根据项目内的需求去判断是否隐藏还是弹出提示无权限等,在本文中不做过多的叙述。
尾言
权限控制在前端更多的应为优化用户体验,除此以外也为应用加固了一层防护,但是需要注意的是前端的相关校验是可以通过技术手段破解的。然而权限问题关乎到软件系统所有数据的安危。
因此为了确保系统平稳运行,前后端都应该做好自己的权限防