前情提要
最近老大分配了一個專案,開發一個給客戶用的後臺系統,要求是除了使用者需要的應用功能以外還要有許可權控制功能。
本來許可權控制這種功能應該是一個後臺專案的基礎功能,那麼應該是可以把這個功能整合開發在原有的後臺系統平臺上,於是想當然的我就看了一下公司以前那個陳舊的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
參考博文地址:
非常感謝大佬們寫的博文指引方向!