前言
上个月,我写了两篇微服务的文章:《.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 ///2 /// 功能权限 3 /// 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 /// 10 /// 判断权限值是否被重复使用 11 /// 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 /// 26 /// 初始化添加预定义权限值 27 /// 28 /// 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 (); 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 (a => true); 79 _mongoRepository.BatchAdd(permList); 80 } 81 82 /// 83 /// 获取当前路径 84 /// 85 /// 86 /// 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 ///2 /// 全局的访问权限控制 3 /// 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 ///2 /// 全局用户权限值查询 3 /// 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 (); 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 }
视图代码
结尾
以上就是我本篇分享的内容,项目是以单体应用提供的,方案思路也适用于前后端分离。最后附上几个系统效果图