前言
上個月,我寫了兩篇微服務的文章:《.Net微服務實戰之技術架構分層篇》與《.Net微服務實戰之技術選型篇》,微服務系列原有三篇,當我憋第三篇的內容時候一直沒有靈感,因此先打算放一放。
本篇文章與原始碼原本打算實在去年的時候完成併發布的,然而我一直忙於公司專案的微服務的實施,所以該篇文章一拖再拖。如今我花了點時間整理了下程式碼,並以此篇文章描述整個實現思路,並開放了原始碼給予需要的人一些參考。
原始碼:https://github.com/SkyChenSky/Sikiro.RBAC
RBAC
Role-Based Access Contro翻譯成中文就是基於角色的訪問控制,文章以下我都用他的簡稱RBAC來描述。
現資訊系統的許可權控制大多數採取RBAC的思想進行實現,其本質思想是對系統各種的操作許可權不是直接授予具體的某個使用者,而是在使用者集合與許可權集合之間建立一個角色,作為間接關聯。每一種角色對應一組相應的許可權。一旦使用者被分配了適當的角色後,該使用者就擁有此角色的所有操作許可權。
通過以上的描述,我們可以分析出以下資訊:
- 使用者與許可權是通過角色間接關聯的
- 角色的本質就是許可權組(許可權集合)
這樣做的好處在於,不必在每次建立使用者時都進行分配許可權的操作,只要分配使用者相應的角色即可,而且角色的許可權變更比使用者的許可權變更要少得多,這樣將簡化使用者的許可權管理,減少系統的開銷。
功能分析
許可權分類
從許可權的作用可以分為三種,功能許可權、訪問許可權、資料許可權:
- 功能許可權
- 功能許可權指系統使用者允許在頁面進行按鈕操作的許可權。如果有許可權則功能按鈕展示,否則隱藏。
- 訪問許可權
- 訪問許可權指系統使用者通過點選按鈕後進行地址的請求訪問的許可權(地址跳轉與介面請求),如果無許可權訪問,則由頁面提示無許可權訪問。
- 資料許可權
- 資料許可權指使用者可訪問系統的資料許可權,不同的使用者可以訪問不同的資料粒度。
資料許可權的實現可大可小,大可大到對條件進行動態配置,小可小到只針對某個維度進行硬編碼。不納入這次的討論範圍。
用例圖
非功能性需求
時效性,直接影響到安全性,既然是許可權控制,那麼理應一修改許可權後就立刻生效。曾經有同行問過我,是不是每一個請求都得去查一次資料庫是否滿足許可權,如果是,資料庫壓力豈不是很大?
安全性,每一個頁面跳轉,每一個讀寫請求都的進行一次許可權驗證,不滿足的許可權的功能按鈕就不需要渲染,避免樣式display:none的情況。
開發效率,許可權控制理應是框架層面的,因此儘可能作為非業務的侵入性,讓開發人員保持原有的資料善增改查與頁面渲染。
技術選型
LayUI
學習門檻極低,開箱即用。其外在極簡,卻又不失飽滿的內在,體積輕盈,元件豐盈,從核心程式碼到 API 的每一處細節都經過精心雕琢,非常適合介面的快速開發,它更多是為服務端程式設計師量身定做,無需涉足各種前端工具的複雜配置,只需面對瀏覽器本身,讓一切你所需要的元素與互動,從這裡信手拈來。作為國人的開源專案,完整的介面文件與Demo示例讓入門者非常友好的上手,開箱即用的Api讓學習成本儘可能的低,其易用性成為快速開發框架的基礎。
MongoDB
主要兩大優勢,無模式與橫向擴充套件。對於許可權模組來說,無需SQL來寫複雜查詢和報表,也不需要使用到多表的強事務,上面提到的時效性的資料庫壓力問題也可以通過分片解決。無模式使得開發人員無需預定義儲存結構,結合MongoDB官方提供的驅動可以做到快速的開發。
資料庫設計
E-R圖
一個管理員可以擁有多個角色,因此管理員與角色是一對多的關聯;角色作為許可權組的存在,又可以選擇多個功能許可權值與選單,所以角色與選單、功能許可權值也是一對多的關係。
類圖
Deparment與Position屬於非核心,可以按照自己的實際業務進行擴充套件。
功能許可權值初始化
隨著業務發展,需求功能是千奇百怪的,根本無法抽象出來,那麼功能按鈕就要隨著業務進行定義。在我的專案裡使用了列舉值進行定義每個功能許可權,通過自定義的PermissionAttribute與響應的action進行繫結,在系統啟動時,通過反射把功能許可權的列舉值與相應的controller、action對映到MenuAction表,列舉值對應code欄位,controller與action拼接後對應url欄位。
已初始化到資料庫的許可權值可以到選單頁把相對應的選單與許可權通過使用者介面關聯起來。
許可權值繫結action
1 [HttpPost] 2 [Permission(PermCode.Administrator_Edit)] 3 public IActionResult Edit(EditModel edit) 4 { 5 //do something 6 7 return Json(result); 8 }
初始化許可權值
1 /// <summary> 2 /// 功能許可權 3 /// </summary> 4 public static class PermissionUtil 5 { 6 public static readonly Dictionary<string, IEnumerable<int>> PermissionUrls = new Dictionary<string, IEnumerable<int>>(); 7 private static MongoRepository _mongoRepository; 8 9 /// <summary> 10 /// 判斷許可權值是否被重複使用 11 /// </summary> 12 public static void ValidPermissions() 13 { 14 var codes = Enum.GetValues(typeof(PermCode)).Cast<int>(); 15 var dic = new Dictionary<int, int>(); 16 foreach (var code in codes) 17 { 18 if (!dic.ContainsKey(code)) 19 dic.Add(code, 1); 20 else 21 throw new Exception($"許可權值 {code} 被重複使用,請檢查 PermCode 的定義"); 22 } 23 } 24 25 /// <summary> 26 /// 初始化新增預定義許可權值 27 /// </summary> 28 /// <param name="app"></param> 29 public static void InitPermission(IApplicationBuilder app) 30 { 31 //驗證許可權值是否重複 32 ValidPermissions(); 33 34 //反射被標記的Controller和Action 35 _mongoRepository = (MongoRepository)app.ApplicationServices.GetService(typeof(MongoRepository)); 36 37 var permList = new List<MenuAction>(); 38 var actions = typeof(PermissionUtil).Assembly.GetTypes() 39 .Where(t => typeof(Controller).IsAssignableFrom(t) && !t.IsAbstract) 40 .SelectMany(t => t.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)); 41 42 //遍歷集合整理資訊 43 foreach (var action in actions) 44 { 45 var permissionAttribute = 46 action.GetCustomAttributes(typeof(PermissionAttribute), false).ToList(); 47 if (!permissionAttribute.Any()) 48 continue; 49 50 var codes = permissionAttribute.Select(a => ((PermissionAttribute)a).Code).ToArray(); 51 var controllerName = action?.ReflectedType?.Name.Replace("Controller", "").ToLower(); 52 var actionName = action.Name.ToLower(); 53 54 foreach (var item in codes) 55 { 56 if (permList.Exists(c => c.Code == item)) 57 { 58 var menuAction = permList.FirstOrDefault(a => a.Code == item); 59 menuAction?.Url.Add($"{controllerName}/{actionName}".ToLower()); 60 } 61 else 62 { 63 var perm = new MenuAction 64 { 65 Id = item.ToString().EncodeMd5String().ToObjectId(), 66 CreateDateTime = DateTime.Now, 67 Url = new List<string> { $"{controllerName}/{actionName}".ToLower() }, 68 Code = item, 69 Name = ((PermCode)item).GetDisplayName() ?? ((PermCode)item).ToString() 70 }; 71 permList.Add(perm); 72 } 73 } 74 PermissionUrls.TryAdd($"{controllerName}/{actionName}".ToLower(), codes); 75 } 76 77 //業務功能持久化 78 _mongoRepository.Delete<MenuAction>(a => true); 79 _mongoRepository.BatchAdd(permList); 80 } 81 82 /// <summary> 83 /// 獲取當前路徑 84 /// </summary> 85 /// <param name="filterContext"></param> 86 /// <returns></returns> 87 public static string CurrentUrl(HttpContext filterContext) 88 { 89 var url = filterContext.Request.Path.ToString().ToLower().Trim('/'); 90 return url; 91 } 92 }
關聯選單與功能許可權
訪問許可權
當所有許可權關係關聯上後,使用者訪問系統時,需要對其所有操作進行攔截與實時的許可權判斷,我們註冊一個全域性的GlobalAuthorizeAttribute,其主要攔截所有已經標識PermissionAttribute的action,查詢該使用者所關聯所有角色的許可權是否滿足允許通過。
我的實現有個細節,給判斷使用者IsSuper==true,也就是超級管理員,如果是超級管理員則繞過所有判斷,可能有人會問為什麼不在角色新增一個名叫超級管理員進行判斷,因為名稱是不可控的,在程式碼邏輯裡並不知道使用者起的所謂的超級管理員,就是我們需要繞過驗證的超級管理員,假如他叫無敵管理員呢?
1 /// <summary> 2 /// 全域性的訪問許可權控制 3 /// </summary> 4 public class GlobalAuthorizeAttribute : System.Attribute, IAuthorizationFilter 5 { 6 #region 初始化 7 private string _currentUrl; 8 private string _unauthorizedMessage; 9 private readonly List<string> _noCheckPage = new List<string> { "home/index", "home/indexpage", "/" }; 10 11 private readonly AdministratorService _administratorService; 12 private readonly MenuService _menuService; 13 14 public GlobalAuthorizeAttribute(AdministratorService administratorService, MenuService menuService) 15 { 16 _administratorService = administratorService; 17 _menuService = menuService; 18 } 19 #endregion 20 21 public void OnAuthorization(AuthorizationFilterContext context) 22 { 23 context.ThrowIfNull(); 24 25 _currentUrl = PermissionUtil.CurrentUrl(context.HttpContext); 26 27 //不需要驗證登入的直接跳過 28 if (context.Filters.Count(a => a is AllowAnonymousFilter) > 0) 29 return; 30 31 var user = GetCurrentUser(context); 32 if (user == null) 33 { 34 if (_noCheckPage.Contains(_currentUrl)) 35 return; 36 37 _unauthorizedMessage = "登入失效"; 38 39 if (context.HttpContext.Request.IsAjax()) 40 NoUserResult(context); 41 else 42 LogoutResult(context); 43 return; 44 } 45 46 //超級管理員跳過 47 if (user.IsSuper) 48 return; 49 50 //賬號狀態判斷 51 var administrator = _administratorService.GetById(user.UserId); 52 if (administrator != null && administrator.Status != EAdministratorStatus.Normal) 53 { 54 if (_noCheckPage.Contains(_currentUrl)) 55 return; 56 57 _unauthorizedMessage = "親~您的賬號已被停用,如有需要請您聯絡系統管理員"; 58 59 if (context.HttpContext.Request.IsAjax()) 60 AjaxResult(context); 61 else 62 AuthResult(context, 403, GoErrorPage(true)); 63 64 return; 65 } 66 67 if (_noCheckPage.Contains(_currentUrl)) 68 return; 69 70 var userUrl = _administratorService.GetUserCanPassUrl(user.UserId); 71 72 // 判斷選單訪問許可權與選單訪問許可權 73 if (IsMenuPass(userUrl) && IsActionPass(userUrl)) 74 return; 75 76 if (context.HttpContext.Request.IsAjax()) 77 AuthResult(context, 200, GetJsonResult()); 78 else 79 AuthResult(context, 403, GoErrorPage()); 80 } 81 }
功能許可權
在許可權驗證通過後,返回view之前,還是利用了Filter進行一個實時的許可權查詢,主要把該使用者所擁有功能許可權值查詢出來通過ViewData["PermCodes"]傳到頁面,然後通過razor進行按鈕的渲染判斷。
然而我在專案中封裝了大部分常用的LayUI控制元件,主要利用.Net Core的TagHelper進行了封裝,TagHelper內部與ViewData["PermCodes"]進行判斷是否輸出HTML。
全域性功能許可權值查詢
1 /// <summary> 2 /// 全域性使用者許可權值查詢 3 /// </summary> 4 public class GobalPermCodeAttribute : IActionFilter 5 { 6 private readonly AdministratorService _administratorService; 7 8 public GobalPermCodeAttribute(AdministratorService administratorService) 9 { 10 _administratorService = administratorService; 11 } 12 13 private static AdministratorData GetCurrentUser(HttpContext context) 14 { 15 return context.User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.UserData)?.Value.FromJson<AdministratorData>(); 16 } 17 18 19 public void OnActionExecuting(ActionExecutingContext context) 20 { 21 ((Controller)context.Controller).ViewData["PermCodes"] = new List<int>(); 22 23 if (context.HttpContext.Request.IsAjax()) 24 return; 25 26 var user = GetCurrentUser(context.HttpContext); 27 if (user == null) 28 return; 29 30 if (user.IsSuper) 31 return; 32 33 ((Controller)context.Controller).ViewData["PermCodes"] = _administratorService.GetActionCode(user.UserId).ToList(); 34 } 35 36 public void OnActionExecuted(ActionExecutedContext context) 37 { 38 } 39 }
LayUI Buttom的TagHelper封裝
1 [HtmlTargetElement("LayuiButton")] 2 public class LayuiButtonTag : TagHelper 3 { 4 #region 初始化 5 private const string PermCodeAttributeName = "PermCode"; 6 private const string ClasstAttributeName = "class"; 7 private const string LayEventAttributeName = "lay-event"; 8 private const string LaySubmitAttributeName = "LaySubmit"; 9 private const string LayIdAttributeName = "id"; 10 private const string StyleAttributeName = "style"; 11 12 [HtmlAttributeName(StyleAttributeName)] 13 public string Style { get; set; } 14 15 [HtmlAttributeName(LayIdAttributeName)] 16 public string Id { get; set; } 17 18 [HtmlAttributeName(LaySubmitAttributeName)] 19 public string LaySubmit { get; set; } 20 21 [HtmlAttributeName(LayEventAttributeName)] 22 public string LayEvent { get; set; } 23 24 [HtmlAttributeName(ClasstAttributeName)] 25 public string Class { get; set; } 26 27 [HtmlAttributeName(PermCodeAttributeName)] 28 public int PermCode { get; set; } 29 30 [HtmlAttributeNotBound] 31 [ViewContext] 32 public ViewContext ViewContext { get; set; } 33 34 #endregion 35 public override async void Process(TagHelperContext context, TagHelperOutput output) 36 { 37 context.ThrowIfNull(); 38 output.ThrowIfNull(); 39 40 var administrator = ViewContext.HttpContext.GetCurrentUser(); 41 if (administrator == null) 42 return; 43 44 var childContent = await output.GetChildContentAsync(); 45 46 if (((List<int>)ViewContext.ViewData["PermCodes"]).Contains(PermCode) || administrator.IsSuper) 47 { 48 foreach (var item in context.AllAttributes) 49 { 50 output.Attributes.Add(item.Name, item.Value); 51 } 52 53 output.TagName = "a"; 54 output.TagMode = TagMode.StartTagAndEndTag; 55 output.Content.SetHtmlContent(childContent.GetContent()); 56 } 57 else 58 { 59 output.TagName = ""; 60 output.TagMode = TagMode.StartTagAndEndTag; 61 output.Content.SetHtmlContent(""); 62 } 63 } 64 }
檢視程式碼
結尾
以上就是我本篇分享的內容,專案是以單體應用提供的,方案思路也適用於前後端分離。最後附上幾個系統效果圖