浅谈前端权限设计方案
前端权限架构的设计一直都是备受关注的技术点.通过给项目引入了权限控制方案,可以满足我们灵活的调整用户访问相关页面的许可.
比如哪些页面向游客开放,哪些页面必须要登录后才能访问,哪些页面只能被某些角色访问(比如超级管理员).有些页面即使用户登录了但受到角色的限制,他也只被允许看到页面的部分内容.
出于实际工作的需要,很多项目(尤其类后台管理系统)需要引入权限控制.倘若权限整体的架构设计的不好或者没有设计,会导致项目中各种权限代码混入业务代码造成结构混乱,其次想给新模块引入权限控制或者功能扩展都十分棘手.
虽然前端在权限层面能做一些事情,但很遗憾真正对权限进行把关的是后端.例如一个软件系统,前端在不写一行权限代码的情况下,当用户进入某个他无权访问的页面时,后端是可以判断他越权访问并拒绝返回数据的.由此可见前端即使不做什么整个系统也是可以正常运行的,但这样应用的体验很不好.另外一个很重要的原因就是前端做的权限校验都是可以被本地数据造假越权通过.
前端如果能判断某用户越权访问页面时,就不要让他进入那张页面后再弹出无权访问的信息提示,因为这样体验很差.最优方案是直接关闭那些页面的入口,只让他看到他能访问的页面.即使他通过输入路径恶意访问,导航最后只会将它带到默认页面或404
页面.
前端做的权限控制大抵是先接受后台发送的权限数据,然后将数据注入到应用中.整个应用于是开始对页面的展现内容以及导航逻辑进行控制,从而达到权限控制的目的.前端做的权限控制虽然能提供一层防护,但根本目的还是为了优化体验.
本文接下来将从下面三个层面,从易到难步步推进,讲述目前前端主流的权限控制方案的实现.(下面代码将会以vue3
和vue-router 4
演示)
- 登录权限控制
- 页面权限控制
- 内容权限控制
登录权限控制要做的事情,是实现哪些页面能被游客访问,哪些页面只有登录后才能被访问.在一些没有引入角色的软件系统中,通过是否登录来评定页面能否被访问在实际工作中非常常见.
实现这个功能也非常简单,首先按照惯例定义一份路由.
export const routes = [
{
path: '/login', //登录页面
name: 'Login',
component: Login,
},
{
path:"/list", // 列表页
name:"List",
component: List,
},
{
path:"/myCenter", // 个人中心
name:"MyCenter",
component: MyCenter,
meta:{
need_login:true //需要登录
}
}
]
假定存在三个页面:登录页、列表页和个人中心页.登录页和列表页所有人都可以访问,但个人中心页面需要登录后才能看到,给该路由添加一个meta
对象,并将need_login
置为true
;
另外对于那些需要登录后才能看到的页面,用户如果没有登录就访问,就将页面跳转到登录页.等到他填写完用户名和密码点击登录后直接跳转到原来他想访问的页面.
在代码层面,通过router.beforeEach
可以轻松实现上述目标,每次页面跳转时都会调用router.beforeEach
包裹的函数,代码如下.
to
是要即将访问的路由信息,从其中拿到need_login
的值可以判断是否需要登录.再从vuex
中拿到用户的登录信息.
如果用户没有登录并且要访问的页面又需要登录时就使用next
跳转到登录页面,并将需要访问的页面路由名称通过redirect_page
传递过去,在登录页面就可以拿到redirect_page
等登录成功后直接跳转.
//vue-router4 创建路由实例
const router = createRouter({
history: createWebHashHistory(),
routes,
});
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();
}
});
页面权限控制
页面权限控制要探讨的问题是如何给不同角色赋予不同的页面访问权限,接下来先了解一下角色的概念.
在一些权限设置比较简单的系统里,使用上面第一种方法就足够了,但如果系统引入了角色,那么就要在上面基础上,再进一步改造增强权限控制的能力.
角色的出现是为了更加个性化配置权限列表.比如当前系统设置三个角色:普通会员,管理员以及超级管理员.普通会员能够浏览软件系统的所有内容,但是它不能编辑和删除内容.管理员拥有普通会员的所有能力,另外它还能删除和编辑内容.超级管理员拥有软件系统所有权限,他单独拥有赋予某个账号为管理员或移除其身份的能力.
一旦软件系统引入了角色的概念,那么每个账户在注册之后就会被赋予相应的角色,从而拥有相应的权限.我们前端要做的事情就是依据不同角色给与它相应页面访问和操作的权限.这里要注意,前端依据的客体是角色,不是某个账户,因为账户是依托于角色的.
普通会员,管理员以及超级管理员这样角色的安排还是一种非常简单的划分方式,在实际项目中,角色的划份要更加细致的多.比如一些常见的后台业务系统,软件系统会按照公司的各个部门来建立角色,诸如市场部,销售部,研发部之类.公司的每个成员就会被划分到相应角色中,从而只具备该角色所拥有的权限.
公司另外一些高层领导他们的账户则会被划分到普通管理员或高级管理员中,那么他们相较于其他角色也会拥有更多的权限.
上面介绍那么多角色的概念其实是为了从全栈的维度去理解权限的设计,但真正落地到前端项目中是不需要去处理角色逻辑的,那部分功能主要由后端完成.
现在假定后端不处理角色完全交给前端来做会出现什么问题.首先前端新建一个配置文件,假定当前系统设定三种角色:普通会员,管理员以及超级管理员以及每个角色能访问的页面列表(伪代码如下).
export const permission_list = {
member:["List","Detail"], //普通会员
admin:["List","Detail","Manage"], // 管理员
super_admin:["List","Detail","Manage","Admin"] // 超级管理员
}
数组里每个值对应着前端路由配置的name
值.普通会员能访问列表页
和详情页
,管理员能额外访问内容管理页面
,超级管理员能额外访问人员管理页面
.
整个运作流程简述如下.当用户登录成功之后,通过接口返回值得知用户数据和所属角色.拿到角色值后就去配置文件里取出该角色能访问的页面列表数组,随后将这部分权限数据加载到应用中从而达到权限控制的目的.
从上面流程看,角色放在前端配置也是可以的.但假如项目已经上线,产品经理要求项目急需增加一个新角色合作伙伴
,并把原来已经存在的用户张三
移动到合作伙伴
角色下面.那这样的变动会导致前端需要修改代码文件,在原来的配置文件上再新建角色来满足这一需求.
由此可见由前端来配置角色列表是非常不灵活且容易出错的,那么最优方案是交给后端去配置.用户一旦登录后,后端接口直接返回该账号拥有的权限列表就行了,至于该账户属于什么角色以及角色拥有的页面权限全部丢给后端去处理.
用户登录成功后,后端接口数据返回如下.
{
user_id:1,
user_name:"张三",
permission_list:["List","Detail","Manage"]
}
前端现在不需要理会张三属于什么角色,只需要按照张三的权限列表给他相应的访问权限就行了,其他都交给后端处理.
通过接口的返回值permission_list
可知,张三能访问列表页
、详情页
以及内容管理页
.我们先回到路由配置页面,看看如何配置.
//静态路由
export const routes = [
{
path: '/login', //登录页面
name: 'Login',
component: Login,
},
{
path:"/myCenter", // 个人中心
name:"MyCenter",
component: MyCenter,
meta:{
need_login:true //需要登录
}
},
{
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
.静态路由里面的页面是所有角色都能访问的,它里面主要区分登录访问和非登录访问,处理的逻辑与上面介绍的登录权限控制
一致.
动态路由dynamic_routes
里面存放的是与角色定制化相关的的页面.现在继续看下面张三的接口数据,该如何给他设置权限.
{
user_id:1,
user_name:"张三",
permission_list:["List","Detail","Manage"]
}
用户登录成功后,一般会将上述接口信息存到vuex
和localStorage
里面.假如此时刷新浏览器,我们就要动态添加路由信息.
import store from "@/store";
export const routes = [...]; //静态路由
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;
核心代码在动态添加路由里面,主要利用了vue-router 4
提供的API
即router.addRoute
,它能够给已经创建的路由实例继续添加路由信息.
我们先从vuex
里面拿到当前用户的权限列表,然后遍历动态路由数组dynamic_routes
,从里面过滤出允许访问的路由,最后将这些路由动态添加到路由实例里.
这样就实现了用户只能按照他对应的权限列表里的规则访问到相应的页面,至于那些他无权访问的页面,路由实例根本没有添加相应的路由信息,因此即使用户在浏览器强行输入路径越权访问也是访问不到的.
由于vue-router 4
废除了之前的router.addRoutes
,换成了router.addRoute
.每一次只能一个个添加路由信息,所以要将allow_routes
遍历循环添加.
动态添加路由这部分代码最好单独封装起来,因为用户第一次使用还没登录时,store.state.user
是为空的,上面动态添加路由的逻辑会被跳过.那么在用户登录成功获取到权限列表的信息后,需要再把上面动态添加路由的逻辑执行一遍.
添加嵌套子路由
假如静态路由的形式如下,现在想把列表页添加到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,
});
切换用户信息是非常常见的功能,但是应用在切换成不同账号后可能会引发一些问题.例如用户先使用超级管理员登录,由于超级管理员能访问所有页面,因此所有页面路由信息都会被添加到路由实例里.
此时该用户退出账号,使用一个普通会员的账号登录.在不刷新浏览器的情况下,路由实例里面仍然存放了所有页面的路由信息,即使当前账号只是一个普通会员,如果他越权访问相关页面,路由还是会跳转的,这样的结果并不是我们想要的.
解决方案有两个.第一是用户每次切换账户后刷新浏览器重新加载,刷新后的路由实例是重新配置的所以可以避免这个问题,但是刷新页面会带来不好的体验.
第二个方案是当用户选择登出后,清除掉路由实例里面处存放的路由栈信息(代码如下).
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" }); //跳转到登录页面
};
移除单个路由主要利用了官方提供的API
,即router.removeRoute
.
路由栈清空后什么页面都不能访问了,甚至登录页面都访问不了.所以需要再把静态的路由列表routes
引入进来,使用router.addRoute
再添加进入.这样就能让路由栈恢复到最初的状态.
内容权限控制
页面权限控制
它能做到让不同角色访问不同的页面,但对于一些颗粒度更小的项目,比如希望不同的角色都能进入页面,但要求看到的页面内容不一样,这就需要对内容进行权限控制了.
假设某个后台业务系统的界面如下图所示.表格里面存放的是列表数据,当点击发布需求时跳转到新增页面.当勾选列表中的某一条数据后,点击修改按钮显示修改该条数据的弹出框.同理点击删除按钮显示删除该条数据的弹出框
假设项目需求该系统存在三个角色:职员、领导和高层领导.职员不具备修改
、删除
以及发布需求
的功能,他只能查看列表
.当职员进入该页面时,页面上只显示列表
内容,其他三个按钮移除.
领导角色保留列表
和发布需求
按钮.高级领导角色保留页面上所有内容.
我们拿到图片后要先要对页面内容整体分析一遍,按照增删查改
四个维度对页面内容进行归类.使用简称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
和权限编码就完成了,剩下的工作都交给自定义指令内部去做.
延伸
如果项目中删除操作并不是单独放置在一个按钮,而是与列表捆绑在一起放在表格的最后一列,如下图所示.
这样的界面样式在实际工作中非常常见,但似乎上面的v-permission
就并不能友好的支持这样的样式.自定义指令在这种情况下虽然不能用,但我们仍然可以采用相同的思路去优化我们现有的代码结构.
例如模板代码如下.整个列表被封装成了一个组件List
,那么在List
内部就可以写很多的逻辑控制。
比如List
组件内也可以通过vuex
拿到该用户在当前页面的权限编码,如果发现具备D
权限就显示列表中最后删除
那一列,否则就不显示.至于整个列表的显示隐藏仍然可以使用v-permission
来控制.
<template>
<div>
<button v-permission="'C'">添加资源</button>
</div>
<!--列表页-->
<List v-permission="'R'">
...
</List>
</template>
动态导航
下图中的动态导航也是实际工作中非常常见的需求,比如销售部所有成员只能看到销售模块下的两个页面,同理采购部成员只能看到采购模块下的页面.
下面侧边栏导航组件需要根据不同权限显示不同的页面结构,以满足不同角色群体的要求.
我们要把这种需要个性化设置的组件与上面使用v-permission
控制的模式区分开.上面那些页面之所以能使用v-permission
来控制,主要原因是因为产品经理在设计整个软件系统的页面时是按照增删查改
的规则进行的.因此我们就能抽象出其中存在的共性与规律,再借助自定义指令来简化权限系统的开发.
但是侧边栏组件一般全局只有一个,没有什么特别的规律而言,那就只需要在组件内部使用v-if
依据权限值动态渲染就行了.
比如后台接口如下:
{
user_id:1,
user_name:"张三",
permission_list:{
"SALE":true, //显示销售大类
"S_NEED":"CR", //权限编码
"S_RESOURCE":"CURD", //权限编码
}
}
张三拥有访问需求
和资源
页面,但注意SALE
并没有与哪个页面对应上,它仅仅只是表示是否显示销售
这个一级导航.
接下来在侧面栏组件通过vuex
拿到权限数据,再动态渲染页面就可以了.
<template>
<div v-if="permission_list['HOME']">系统首页</div>
<div v-if="permission_list['SALE']">
<p>销售</p>
<div v-if="permission_list['S_NEED']">需求</div>
<div v-if="permission_list['S_RESOURCE']">资源</div>
</div>
<div v-if="permission_list['PURCHASE']">
<p>采购</p>
<div v-if="permission_list['P_NEED']">需求</div>
<div v-if="permission_list['P_RESOURCE']">资源</div>
</div>
</template>
尾言
前端提供的权限控制为应用加固了一层保险,但同时也要警惕前端设定的校验都是可以通过技术手段破解的.权限问题关乎到软件系统所有数据的安危,重要性不言而喻.
为了确保系统平稳运行,前后端都应该做好自己的权限防护.
原文链接:https://juejin.cn/post/6949453195987025927