打造自己的系統權限控制


前情提要

  最近老大分配了一個項目,開發一個給客戶用的后台系統,要求是除了用戶需要的應用功能以外還要有權限控制功能。

  本來權限控制這種功能應該是一個后台項目的基礎功能,那么應該是可以把這個功能集成開發在原有的后台系統平台上,於是想當然的我就看了一下公司以前那個陳舊的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>
樹形導航菜單組件(MenuTree)

  在這里貼出最終的實現效果圖:

  

 

 

  按鈕權限控制

   前面我們實現了菜單權限的控制,接着就來實現一下按鈕權限控制。

  討論一個具體的場景:阿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 }
hasApiPerms函數

  

二、后端權限控制實現:

  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

           https://github.com/minisen/minisen-admin-ui.git

    .net core后端代碼:https://gitee.com/xiaosen123/minisen-admin-backend.git

 

  參考博文地址:

  1、vue+element ui 實現權限管理系統

  2、Vue2.0用戶權限控制解決方案

  非常感謝大佬們寫的博文指引方向!

 

 

手機:18970302807 | QQ:2822737354 | 郵箱:2822737354@qq.com

個人博客網站:minisen.top

現居地:深圳市龍華區

歡迎朋友們一起交流,在附近的朋友們可聊可約飯~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM