前情提要
最近老大分配了一个项目,开发一个给客户用的后台系统,要求是除了用户需要的应用功能以外还要有权限控制功能。
本来权限控制这种功能应该是一个后台项目的基础功能,那么应该是可以把这个功能集成开发在原有的后台系统平台上,于是想当然的我就看了一下公司以前那个陈旧的webform后台系统,一言难尽的滋味涌上心头,找来找去,我只找到了菜单权限配置。
哦!我滴个乖乖,到头来还是得自己手撸(翻白眼)。
好在我是个能认清自己位置的程序员。。。
所以,不废话,直接硬钢。。。
一、vue前端的权限控制实现
首先要搞清楚的一个问题:what is 权限控制?
从权限的角度来看,服务器端提供的一切服务都是资源,例如,请求方法+请求地址(Get + http://127.0.0.1:8080/api/xxx?id=123) 就是一个资源,权限是对特定资源的访问许可,所谓权限控制,也就是确保用户只能访问到被分配的资源。
如果往下细分的话,那么权限又分为菜单权限与按钮权限。
菜单权限的意思从字面就能看出来,就是用户能看到的被分配的导航菜单栏项。
按钮权限其实指的不仅仅指的是页面中按钮的操作权限,还指在页面中所有的组成元素的操作权限,例如:点击一个按钮删除一条数据、点击一个下拉框动态加载数据等,这些由页面元素的动作带来的资源访问都属于按钮权限讨论的范围。
菜单权限怎么搞,动态路由来帮忙
权限控制的第一层控制就是菜单权限的控制,用户想要进入想看的页面,就必须要先过这一关,就像是去餐馆里面吃饭,老板要先给你一份菜单才能点菜。
在vue项目中对菜单权限的控制实际上就是对路由的控制,利用vue-router的动态路由特性,我们可以在项目运行过程中通过代码动态地添加路由对象,这样就可以实现针对不同的用户展现不同菜单的效果。
那么要在什么地方来加载动态路由?又以什么样的方式来筛选路由信息呢?
自然而然地就会想到,我们可以在路由导航守卫中加载菜单和路由数据。大致的流程如下:
第一步,向服务器端请求用户的拥有的菜单权限信息,获得的数据为菜单权限信息的数组,Like This:
1 //从服务端请求到的菜单权限信息数组内容 2 [ 3 { 4 "id": "2c9180895e13261e015e13469b7e0002", 5 "name": "系統管理", 6 "parent_id": "-1", 7 "route": "", 8 "isMenuItem": false, 9 "icon": "el-icon-receiving" 10 }, 11 { 12 "id": "2c9180895e13261e015e13469b7e0003", 13 "name": "账号管理", 14 "parent_id": "2c9180895e13261e015e13469b7e0002", 15 "route": "sys/account", 16 "isMenuItem": true, 17 "icon": "el-icon-receiving" 18 }, 19 { 20 "id": "2c9180895e13261e015e13469b7e0004", 21 "name": "角色管理", 22 "parent_id": "2c9180895e13261e015e13469b7e0002", 23 "route": "sys/role", 24 "isMenuItem": true, 25 "icon": "el-icon-receiving" 26 }, 27 { 28 "id": "2c9180895e13261e015e13469b7e0005", 29 "name": "菜單管理", 30 "parent_id": "2c9180895e13261e015e13469b7e0002", 31 "route": "sys/menu", 32 "isMenuItem": true, 33 "icon": "el-icon-receiving" 34 } 35 ]
第二步,在前端将以上菜单权限信息数组解析成树形结构的数据,作为生成树形导航菜单的数据源,就以上面的数据为例,解析转化后的树形结构的数据如下;
1 //树形结构菜单信息数据 2 [ 3 { 4 "id": "2c9180895e13261e015e13469b7e0002", 5 "name": "系統管理", 6 "parent_id": "-1", 7 "route": "", 8 "isMenuItem": false, 9 "icon": "el-icon-receiving", 10 "chilrden": [ 11 { 12 "id": "2c9180895e13261e015e13469b7e0003", 13 "name": "账号管理", 14 "parent_id": "2c9180895e13261e015e13469b7e0002", 15 "route": "sys/account", 16 "isMenuItem": true, 17 "icon": "el-icon-receiving" 18 }, 19 { 20 "id": "2c9180895e13261e015e13469b7e0004", 21 "name": "角色管理", 22 "parent_id": "2c9180895e13261e015e13469b7e0002", 23 "route": "sys/role", 24 "isMenuItem": true, 25 "icon": "el-icon-receiving" 26 }, 27 { 28 "id": "2c9180895e13261e015e13469b7e0005", 29 "name": "菜單管理", 30 "parent_id": "2c9180895e13261e015e13469b7e0002", 31 "route": "sys/menu", 32 "isMenuItem": true, 33 "icon": "el-icon-receiving" 34 } 35 ] 36 } 37 38 ]
第三步,根据以上树形结构的菜单权限信息数据,以递归的方式逐条数据对应生成vue路由对象,添加到vue路由中,到这里就完成了动态路由的注册。其中,除了登陆页面的路由与首页路由是以静态的方式写死在路由列表中,其他的路由都是使用动态加载的方式添加到路由列表中。具体核心代码如下;
1 /** 2 * 添加动态(菜单)路由 3 * @param {*} menuList 菜单列表 4 * @param {*} routes 递归创建的动态(菜单)路由 5 */ 6 function addDynamicRoutes (menuList = [], routes = []) { 7 var temp = [] 8 for (var i = 0; i < menuList.length; i++) { 9 if (menuList[i].children && menuList[i].children.length >= 1) { 10 temp = temp.concat(menuList[i].children) 11 } else if (menuList[i].route && /\S/.test(menuList[i].route)) { 12 menuList[i].route = menuList[i].route.replace(/^\//, '') 13 // 创建路由配置 14 var route = { 15 path: menuList[i].route, 16 component: null, 17 name: menuList[i].name 18 } 19 let path = getIFramePath(menuList[i].route) 20 if (path) { 21 // 如果是嵌套页面, 通过iframe展示 22 route['path'] = path 23 route['component'] = IFrame 24 // 存储嵌套页面路由路径和访问URL,以便IFrame组件根据path检索url进行页面的展示 25 let url = getIFrameUrl(menuList[i].route) 26 let iFrameUrl = {'path':path, 'url':url} 27 store.commit('addIFrameUrl', iFrameUrl) 28 } else { 29 try { 30 // 根据菜单URL动态加载vue组件,这里要求vue组件须按照url路径存储 31 // 如url="sys/user",则组件路径应是"@/views/sys/user.vue",否则组件加载不到 32 let url = helper.urlToHump(menuList[i].route) 33 route['component'] = resolve => require([`@/views/${url}`], resolve) 34 35 } catch (e) {} 36 } 37 routes.push(route) 38 } 39 } 40 if (temp.length >= 1) { 41 addDynamicRoutes(temp, routes) 42 } else { 43 console.log('动态路由加载...') 44 console.log(routes) 45 console.log('动态路由加载完成.') 46 } 47 return routes 48 }
在生成导航菜单的时候借助Elment-UI的组件,封装一个可以递归的导航菜单组件,用来展示树形的导航菜单效果,树形组件的代码也在这里贴出供参考:
1 <template> 2 <el-submenu v-if="menu.children && menu.children.length >= 1" :index="'' + menu.id"> 3 <template slot="title"> 4 <i :class="menu.icon" ></i> 5 <span slot="title">{{menu.name}}</span> 6 </template> 7 <MenuTree v-for="item in menu.children" :key="item.id" :menu="item"></MenuTree> 8 </el-submenu> 9 <el-menu-item v-else :index="'' + menu.id" @click="handleRoute(menu)"> 10 <i :class="menu.icon"></i> 11 <span slot="title">{{menu.name}}</span> 12 </el-menu-item> 13 </template> 14 15 <script> 16 import { getIFrameUrl, getIFramePath } from '@/utils/iframe' 17 export default { 18 name: 'MenuTree', 19 props: { 20 menu: { 21 type: Object, 22 required: true 23 } 24 }, 25 methods: { 26 handleRoute (menu) { 27 // 如果是嵌套页面,转换成iframe的path 28 let path = getIFramePath(menu.route) 29 if(!path) { 30 path = menu.route 31 } 32 // 通过菜单URL跳转至指定路由 33 this.$router.push("/" + path) 34 } 35 } 36 } 37 </script>
在这里贴出最终的实现效果图:
按钮权限控制
前面我们实现了菜单权限的控制,接着就来实现一下按钮权限控制。
讨论一个具体的场景:阿J和阿Q两个人同时都有xxx页面的访问权限,现在规定页面中的列表数据的删除操作,阿J可以执行,但是阿Q不能执行。
那么要如何去满足以上这个权限控制的场景呢,在这里需要实现的就是按钮权限控制。
按钮权限的控制在视图层是如何体现的?这时你的脑子里可能就是这样一幅画面,阿J所看到的界面上有删除按钮,阿Q的界面上没有删除按钮。对,最终呈现出来的效果就是如此。
问题来到了:我该如何用代码来实现?
我们先来理清一下思路,首先,对于用户来说,每一个请求的api就是一个资源,前面有说到一个api资源的表现形式是这样的:请求方式+请求地址(例:GET,http://192.168.1.101/api/xxxxx),那么一个用户所拥有的api权限集合可以这样表示:
1 let permissions = { 2 "get,/resources":true, 3 "delete,/resources":true, 4 "post,/resources":true, 5 "put,/resources":true, 6 ... 7 }
那api权限与按钮权限之间又是什么关系呢?答案是:一个按钮事件触发以后可能会执行一个或者多个api资源请求,当然,也可能一个api请求也不会执行。对于这种按钮与api权限之间不确定的对应关系,其实也很好解决,就像下面这段代码:
1 let has = function(permission){ 2 if(!permissions[permission]){ 3 return false; 4 } 5 return true; 6 } 7 8 Vue.directive('has', { 9 bind: function (el, binding) { 10 if(!has(binding.value)){ 11 el.parentNode.removeChild(el); 12 } 13 } 14 }); 15 16 //用法: 17 <btn v-has='get,/sources'>按钮</btn> 18 19 //或者 20 <div v-if="has('get,/sources') && something"> 21 一个需要同时具备'get,/sources'权限和somthing为真值才显示的div 22 </div>
这段代码借助了vue的自定义指令实现了指令v-has,可以用这个指令来绑定按钮与api权限之间一对一的关系,如果想一个按钮绑定多个api权限,那么可以使用v-if的用法,调用has函数,如上代码所示。
代码是写出来了,思路也很清晰,但是,问题还是有的。一个系统中少说也有十几个页面,这些页面中与api权限有关联的按钮加起来保守的说也有几十上百个吧,那么这百十个按钮都要像这样让程序员人工绑定吗?而且随着系统慢慢的壮大与改变,按钮会越来越多,而且可能还会修改以前的按钮api权限,这就造成了程序员的脱发问题。
于是,不想谢顶的哥们就跳出来说,要不我们不要这么绑定来绑定去了,直接在api权限的层面来控制api资源的请求,直接写一个请求过滤器就行了,这样的话,没有api权限的用户就算点了按钮也不会发送请求,不是达到了按钮权限控制的效果了吗。然后,把代码也贴出来了:
1 axios.interceptors.request.use(function (config) { 2 let permission = config.method + config.url.replace(config.baseURL,','); 3 if(!has(permission)){ 4 //验证不通过 5 return Promise.reject({ 6 message: `no permission` 7 }); 8 } 9 return config; 10 });
但如果仅仅这样做权限控制,界面上将显示出所有的按钮,用户看到的按钮却不一定可以点击,这种体验我认为只能停留在理论层面,根本无法应用到实际产品中。请求控制可以作为整个控制体系的第二道防线,或某些特殊情况下的辅助手段,最终还是要回到按钮控制的思路上来。
于是,我给出一个稍微能减轻程序员负担的方案:让按钮和请求联系起来,比如说按钮涉及一个名称为A的请求,那么我希望权限指令可以这样写。
1 <btn v-has="[A]" @click="Fn">按钮</btn>
在这里,A是一个包含两个属性的对象:
1 const A = { 2 p: ['put,/menu/**'], 3 r: params => { 4 return axios.put(`/menu/${params.id}`, params) 5 } 6 }; 7 8 //用作权限: 9 <btn v-has="[A]" @click="Fn">按钮</btn> 10 11 //用作请求: 12 function Fn(){ 13 A.r().then((res) => {}) 14 }
我们把api请求资源与要调用的请求方法绑定到了一个对象中,通常我们会将项目里所有的api放在一个api模块里集中管理,在写api时顺便就把权限给维护了,换来的是在组件界面里可以直接用请求名称来描述权限,这样的话我们在使用v-has指令绑定api请求资源的时候就不用频繁地在界面和api模块之间来回奔波了,一定程度上实现了关注点分离,减轻了程序员的负担,让头发多一点,视力好一点。
当然,相应地就要稍微改动一下has方法:接收请求名称的数组参数,允许多个权限联合校验,因为在很多情况下一个按钮触发发送的请求不止一个,允许多个权限绑定到按钮可以尽可能地降低按钮权限的维护成本,像这样使用:
1 <btn v-has="[A,B,C]" @click="Fn">按钮</btn>
同时贴出权限验证的hasApiPerms函数代码供参考:
1 function hasApiPerms (apiPermArray) { 2 3 let hasApiPerms = JSON.parse(sessionStorage.getItem('setApiPerms')) 4 let RequiredPermissions = [] 5 let permission = true 6 7 if (Array.isArray(apiPermArray)) { 8 apiPermArray.forEach(e => { 9 if(e && e.p){ 10 RequiredPermissions = RequiredPermissions.concat(e.p.map(hashUrl => helper.urlToHump(hashUrl.replace(/\s*/g,"")))) 11 } 12 }); 13 } else { 14 if(apiPermArray && apiPermArray.p){ 15 RequiredPermissions = apiPermArray.p.map(hashUrl => helper.urlToHump(hashUrl.replace(/\s*/g,""))) 16 } 17 18 } 19 20 for(let i=0;i<RequiredPermissions.length;i++){ 21 let p = helper.urlToHump(RequiredPermissions[i].replace(/\s*/g,"")) 22 if (!hasApiPerms[p]) { 23 24 console.log('apiPerms') 25 console.log(hasApiPerms) 26 permission = false 27 break 28 } 29 } 30 31 return permission 32 }
二、后端权限控制实现:
1、表设计
表设计采用的是RBAC(Role-Base Access Control)模型,主要是围绕 用户-角色-权限 三个表的关系来进行表的设计,其中权限表包括了Api权限与菜单权限的数据。
2、关键代码实现
在 .net mvc 项目中,api权限校验的操作一般都会放在 IAuthorizationFilter 过滤器中实现,利用AOP原理,在每一个Action执行前进行Api权限校验。
1)根据这个思路,首先定义一个Attribute用来标记Action的权限信息:
1 [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] 2 public class ApiPermAttribute : Attribute 3 { 4 public string ApiUrl { get; set; } 5 6 public ApiPermAttribute(string apiUrl) 7 { 8 this.ApiUrl = apiUrl; 9 } 10 }
2)在需要进行api权限验证的action上标记:
1 [ApiPerm("Api/Account/GetItemsPaged")] 2 [HttpGet] 3 public ActionResult getItemsPaged(int? pageNum, int? pageSize, string name, string account) 4 { 5 var items = AccountService.SearchItemsPaged(pageNum, pageSize, name, account).ToList(); 6 7 return JsonView(items); 8 }
3)进行权限验证的过滤器:
1 /// <summary> 2 /// Api访问权限检查 3 /// </summary> 4 /// <param name="filterContext"></param> 5 public void OnAuthorization(AuthorizationContext filterContext) 6 { 7 var apiPermAttrObjs = filterContext.ActionDescriptor.GetCustomAttributes(typeof(ApiPermAttribute), false); 8 if (null == apiPermAttrObjs || apiPermAttrObjs.Length <= 0) return; 9 10 //check login state 11 var loginState = filterContext.HttpContext.Session["LoginState"]; 12 13 if (null == loginState || !(bool)loginState) 14 { 15 filterContext.Result = new JsonNetResult { Data = new AjaxResult { Status = "error", ErrorMsg = "redirect to login" } }; 16 return; 17 } 18 19 //check api permission 20 var apiPermAttr = apiPermAttrObjs[0] as ApiPermAttribute; 21 string loginAccountId = filterContext.HttpContext.Session["LoginUserId"].ToString(); 22 23 if (!accountService.JudgeIfAccountHasPerms(loginAccountId, apiPermAttr.ApiUrl)) 24 { 25 filterContext.Result = new JsonNetResult { Data = new AjaxResult { Status = "error", ErrorMsg = "you have no permission of current operation" } }; 26 return; 27 } 28 }
菜单权限校验是在vue前端进行的,后端只需要提供给前端当前登陆用户的所拥有的的菜单权限数组即可,不用做其他的处理。
由于菜单表的设计是树结构,这里就有一个难点就是如何根据菜单项查询出祖宗结点的所有目录项,在这里贴出oracle数据库中查询菜单树的sql,属于比较少用的递归查询:
1 select 2 distinct D.* 3 from 4 B_MENU D 5 start with D.ID in ( 6 select 7 A.MENUID 8 from 9 B_PERMISSION A, 10 R_ADMIN_USER_ROLE B, 11 R_ROLE_PERMISSION C 12 where 13 A.PERMISSIONTYPE = 2 and 14 A.ID = C.PERMISSIONID and 15 B.ROLEID = C.ROLEID and 16 B.ADMINUSERID = {0} and 17 A.DELFLAG = 0 and B.DELFLAG = 0 and C.DELFLAG = 0 18 ) connect by prior D.PARENTID = D.ID
以上只是大致地理出了在设计权限控制系统是时的基本思路与部分关键代码实现,更详细的细节还是需要看源码才行,在这里贴出源码地址,也欢迎交流~~~
gitee源码地址:
vue前端代码:https://gitee.com/xiaosen123/minisen-admin-ui.git
.net core后端代码:https://gitee.com/xiaosen123/minisen-admin-backend.git
参考博文地址:
非常感谢大佬们写的博文指引方向!