ASP.NET Core 2.0 中的动态角色授权






4.94/5 (12投票s)
您已经了解了基于角色的授权在 ASP.NET Core 中的工作方式,但如果您不想在授权属性中硬编码角色,或者稍后创建角色并指定它可以访问哪个控制器和操作,而无需触及源代码,该怎么办?
引言
您已经了解了基于角色的授权在 ASP.NET Core 中的工作方式。
[Authorize(Roles = "Administrator")]
public class SomeController : Controller
{
}
但是,如果您不想在授权属性中硬编码角色,或者稍后创建角色并指定它可以访问哪个控制器和操作,而无需触及源代码,该怎么办?
Using the Code
让我们开始我们的旅程。创建 ASP.NET Core Web 应用程序项目,并将身份验证更改为“个人用户帐户”。
创建项目后,我们需要做的第一件事是查找项目中的所有控制器。在 *Models* 文件夹中添加两个新类 MvcControllerInfo
和 MvcActionInfo
public class MvcControllerInfo
{
public string Id => $"{AreaName}:{Name}";
public string Name { get; set; }
public string DisplayName { get; set; }
public string AreaName { get; set; }
public IEnumerable<MvcActionInfo> Actions { get; set; }
}
public class MvcActionInfo
{
public string Id => $"{ControllerId}:{Name}";
public string Name { get; set; }
public string DisplayName { get; set; }
public string ControllerId { get; set; }
}
在 *Services* 文件夹中添加另一个类 MvcControllerDiscovery
以发现所有控制器和操作
public class MvcControllerDiscovery : IMvcControllerDiscovery
{
private List<MvcControllerInfo> _mvcControllers;
private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider;
public MvcControllerDiscovery
(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider)
{
_actionDescriptorCollectionProvider = actionDescriptorCollectionProvider;
}
public IEnumerable<MvcControllerInfo> GetControllers()
{
if (_mvcControllers != null)
return _mvcControllers;
_mvcControllers = new List<MvcControllerInfo>();
var items = _actionDescriptorCollectionProvider
.ActionDescriptors.Items
.Where(descriptor => descriptor.GetType() == typeof(ControllerActionDescriptor))
.Select(descriptor => (ControllerActionDescriptor)descriptor)
.GroupBy(descriptor => descriptor.ControllerTypeInfo.FullName)
.ToList();
foreach (var actionDescriptors in items)
{
if (!actionDescriptors.Any())
continue;
var actionDescriptor = actionDescriptors.First();
var controllerTypeInfo = actionDescriptor.ControllerTypeInfo;
var currentController = new MvcControllerInfo
{
AreaName = controllerTypeInfo.GetCustomAttribute<AreaAttribute>()?.RouteValue,
DisplayName =
controllerTypeInfo.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName,
Name = actionDescriptor.ControllerName,
};
var actions = new List<MvcActionInfo>();
foreach (var descriptor in actionDescriptors.GroupBy
(a => a.ActionName).Select(g => g.First()))
{
var methodInfo = descriptor.MethodInfo;
actions.Add(new MvcActionInfo
{
ControllerId = currentController.Id,
Name = descriptor.ActionName,
DisplayName =
methodInfo.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName,
});
}
currentController.Actions = actions;
_mvcControllers.Add(currentController);
}
return _mvcControllers;
}
IActionDescriptorCollectionProvider
提供 ActionDescriptor
的缓存集合,每个描述符代表一个操作。每个 MvcControllerInfo
都包含其 Actions。
打开 Startup
类,并在 Configure
方法中注册 MvcControllerDiscovery
依赖项。
services.AddSingleton<IMvcControllerDiscovery, MvcControllerDiscovery>();
是时候添加角色控制器来管理角色了。在 *Controller* 文件夹中,创建 RoleController
,然后添加 Create
操作
public class RoleController : Controller
{
private readonly IMvcControllerDiscovery _mvcControllerDiscovery;
public RoleController(IMvcControllerDiscovery mvcControllerDiscovery)
{
_mvcControllerDiscovery = mvcControllerDiscovery;
}
// GET: Role/Create
public ActionResult Create()
{
ViewData["Controllers"] = _mvcControllerDiscovery.GetControllers();
return View();
}
}
转到 *Models* 文件夹并添加 RoleViewModel
类
public class RoleViewModel
{
[Required]
[StringLength(256, ErrorMessage = "The {0} must be at least {2} characters long.")]
public string Name { get; set; }
public IEnumerable<MvcControllerInfo> SelectedControllers { get; set; }
}
public class RoleViewModel
{
[Required]
[StringLength(256, ErrorMessage = "The {0} must be at least {2} characters long.")]
public string Name { get; set; }
public IEnumerable<MvcControllerInfo> SelectedControllers { get; set; }
}
然后在 *View* 文件夹中,添加另一个文件夹并将其命名为 *Role*,然后添加 *Create.cshtml* 视图。我使用了 jqeury.bonsai 来显示控制器和操作层次结构。
@model RoleViewModel
@{
ViewData["Title"] = "Create Role";
var controllers = (IEnumerable<MvcControllerInfo>)ViewData["Controllers"];
}
@section Header {
<link href="~/lib/jquery-bonsai/jquery.bonsai.css" rel="stylesheet" />
}
<h2>Create Role</h2>
<hr />
<div class="row">
<div class="col-md-6">
<form asp-action="Create" class="form-horizontal">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group">
<label asp-for="Name" class="control-label col-md-2"></label>
<div class="col-md-10">
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">Access List</label>
<div class="col-md-9">
<ol id="tree">
@foreach (var controller in controllers)
{
string name;
{
name = controller.DisplayName ?? controller.Name;
}
<li class="controller" data-value="@controller.Name">
<input type="hidden" class="area" value="@controller.AreaName" />
@name
@if (controller.Actions.Any())
{
<ul>
@foreach (var action in controller.Actions)
{
{
name = action.DisplayName ?? action.Name;
}
<li data-value="@action.Name">@name</li>
}
</ul>
}
</li>
}
</ol>
</div>
</div>
<div class="form-group">
<input type="submit" value="Create" class="btn btn-default" />
</div>
</form>
</div>
</div>
<div>
<a asp-action="Index">Back to List</a>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script src="~/lib/jquery-qubit/jquery.qubit.js"></script>
<script src="~/lib/jquery-bonsai/jquery.bonsai.js"></script>
<script>
$(function () {
$('#tree').bonsai({
expandAll: false,
checkboxes: true,
createInputs: 'checkbox'
});
$('form').submit(function () {
var i = 0, j = 0;
$('.controller > input[type="checkbox"]:checked,
.controller > input[type="checkbox"]:indeterminate').each(function () {
var controller = $(this);
if ($(controller).prop('indeterminate')) {
$(controller).prop("checked", true);
}
var controllerName = 'SelectedControllers[' + i + ']';
$(controller).prop('name', controllerName + '.Name');
var area = $(controller).next().next();
$(area).prop('name', controllerName + '.AreaName');
$('ul > li > input[type="checkbox"]:checked',
$(controller).parent()).each(function () {
var action = $(this);
var actionName = controllerName + '.Actions[' + j + '].Name';
$(action).prop('name', actionName);
j++;
});
j = 0;
i++;
});
return true;
});
});
</script>
}
是时候保存角色了,但在此之前,我们需要进行一些更改。
- 首先,在 *Models* 文件夹中创建新类
ApplicationRole
public class ApplicationRole : IdentityRole { public string Access { get; set; } }
- 打开
ApplicationDbContext
,将其更改为public class ApplicationDbContext : IdentityDbContext<ApplicationUser, ApplicationRole, string>
- 打开
Startup
类,并在Configure
方法中,将services.AddIdentity...
更改为services.AddIdentity<ApplicationUser, ApplicationRole>() .AddEntityFrameworkStores<ApplicationDbContext>() .AddDefaultTokenProviders();
- 最后,添加新的 EF 迁移,在 nuget 包管理器控制台中,运行
Add-Migration RoleAccessAdded
命令,新的迁移将被添加到Data->Migrations
文件夹。返回到
RoleController
并添加 Create 操作的post
方法。private readonly IMvcControllerDiscovery _mvcControllerDiscovery; private readonly RoleManager<ApplicationRole> _roleManager; public RoleController(IMvcControllerDiscovery mvcControllerDiscovery, RoleManager<ApplicationRole> roleManager) { _mvcControllerDiscovery = mvcControllerDiscovery; _roleManager = roleManager; } // POST: Role/Create [HttpPost] [ValidateAntiForgeryToken] public async Task<ActionResult> Create(RoleViewModel viewModel) { if (!ModelState.IsValid) { ViewData["Controllers"] = _mvcControllerDiscovery.GetControllers(); return View(viewModel); } var role = new ApplicationRole { Name = viewModel.Name }; if (viewModel.SelectedControllers != null && viewModel.SelectedControllers.Any()) { foreach (var controller in viewModel.SelectedControllers) foreach (var action in controller.Actions) action.ControllerId = controller.Id; var accessJson = JsonConvert.SerializeObject(viewModel.SelectedControllers); role.Access = accessJson; } var result = await _roleManager.CreateAsync(role); if (result.Succeeded) return RedirectToAction(nameof(Index)); foreach (var error in result.Errors) ModelState.AddModelError("", error.Description); ViewData["Controllers"] = _mvcControllerDiscovery.GetControllers(); return View(viewModel); }
选定的控制器序列化为 json 并存储在角色
Access
属性中。您可以使用
DisplayName
属性修饰控制器和操作,以向用户显示更具意义的名称,而不是控制器和操作名称。[DisplayName("Access Management")] public class AccessController : Controller { // GET: Access [DisplayName("Access List")] public async Task<ActionResult> Index() }
下一步是将角色分配给用户。添加新的视图模型并将其命名为 UserRoleViewModel 和新的控制器 AccessController。
AccessController
非常简单,没有任何复杂之处。将角色分配给用户后,现在我们可以检查用户是否具有访问控制器和操作的权限。添加新文件夹 *Filters*,然后将新类
DynamicAuthorizationFilter
添加到该文件夹,并且DynamicAuthorizationFilter
继承IAsyncAuthorizationFilter
。public class DynamicAuthorizationFilter : IAsyncAuthorizationFilter { private readonly ApplicationDbContext _dbContext; public DynamicAuthorizationFilter(ApplicationDbContext dbContext) { _dbContext = dbContext; } public async Task OnAuthorizationAsync(AuthorizationFilterContext context) { if (!IsProtectedAction(context)) return; if (!IsUserAuthenticated(context)) { context.Result = new UnauthorizedResult(); return; } var actionId = GetActionId(context); var userName = context.HttpContext.User.Identity.Name; var roles = await ( from user in _dbContext.Users join userRole in _dbContext.UserRoles on user.Id equals userRole.UserId join role in _dbContext.Roles on userRole.RoleId equals role.Id where user.UserName == userName select role ).ToListAsync(); foreach (var role in roles) { var accessList = JsonConvert.DeserializeObject<IEnumerable<MvcControllerInfo>>(role.Access); if (accessList.SelectMany(c => c.Actions).Any(a => a.Id == actionId)) return; } context.Result = new ForbidResult(); } private bool IsProtectedAction(AuthorizationFilterContext context) { if (context.Filters.Any(item => item is IAllowAnonymousFilter)) return false; var controllerActionDescriptor = (ControllerActionDescriptor)context.ActionDescriptor; var controllerTypeInfo = controllerActionDescriptor.ControllerTypeInfo; var actionMethodInfo = controllerActionDescriptor.MethodInfo; var authorizeAttribute = controllerTypeInfo.GetCustomAttribute<AuthorizeAttribute>(); if (authorizeAttribute != null) return true; authorizeAttribute = actionMethodInfo.GetCustomAttribute<AuthorizeAttribute>(); if (authorizeAttribute != null) return true; return false; } private bool IsUserAuthenticated(AuthorizationFilterContext context) { return context.HttpContext.User.Identity.IsAuthenticated; } private string GetActionId(AuthorizationFilterContext context) { var controllerActionDescriptor = (ControllerActionDescriptor)context.ActionDescriptor; var area = controllerActionDescriptor.ControllerTypeInfo. GetCustomAttribute<AreaAttribute>()?.RouteValue; var controller = controllerActionDescriptor.ControllerName; var action = controllerActionDescriptor.ActionName; return $"{area}:{controller}:{action}"; } }
IsProtectedAction
检查请求的控制器和操作是否具有Authorize
属性,以及控制器是否具有Authorize
属性,操作是否具有AllowAnonymous
属性,因为我们不想检查对未受保护的控制器和操作的访问权限。IsUserAuthenticated
检查用户是否已通过身份验证,如果用户未通过身份验证,则将返回UnauthorizedResult
。- 然后,我们获取用户角色,并检查这些角色是否具有对请求控制器的访问权限,如果用户没有访问权限,则将返回
ForbidResult
。
现在,我们需要在 Startup
类中全局注册此筛选器,并将 services.AddMvc()
修改为此
services.AddMvc(options => options.Filters.Add(typeof(DynamicAuthorizationFilter)));
就是这样!现在我们可以创建角色,并将角色分配给用户,并检查用户对每个请求的访问权限。
最后,我们需要一个自定义的 TageHelper
来检查用户是否具有查看链接的权限。
[HtmlTargetElement("secure-content")]
public class SecureContentTagHelper : TagHelper
{
private readonly ApplicationDbContext _dbContext;
public SecureContentTagHelper(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
[HtmlAttributeName("asp-area")]
public string Area { get; set; }
[HtmlAttributeName("asp-controller")]
public string Controller { get; set; }
[HtmlAttributeName("asp-action")]
public string Action { get; set; }
[ViewContext, HtmlAttributeNotBound]
public ViewContext ViewContext { get; set; }
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
output.TagName = null;
var user = ViewContext.HttpContext.User;
if (!user.Identity.IsAuthenticated)
{
output.SuppressOutput();
return;
}
var roles = await (
from usr in _dbContext.Users
join userRole in _dbContext.UserRoles on usr.Id equals userRole.UserId
join role in _dbContext.Roles on userRole.RoleId equals role.Id
where usr.UserName == user.Identity.Name
select role
).ToListAsync();
var actionId = $"{Area}:{Controller}:{Action}";
foreach (var role in roles)
{
var accessList =
JsonConvert.DeserializeObject<IEnumerable<MvcControllerInfo>>(role.Access);
if (accessList.SelectMany(c => c.Actions).Any(a => a.Id == actionId))
return;
}
output.SuppressOutput();
}
}
在每个视图中,将锚标记包装在 secure-content
标记中
<ul class="nav navbar-nav">
<li><a asp-area="" asp-controller="Home" asp-action="Index">Home</a></li>
<li><a asp-area="" asp-controller="Home" asp-action="About">About</a></li>
<li><a asp-area="" asp-controller="Home" asp-action="Contact">Contact</a></li>
<secure-content asp-area="" asp-controller="Role" asp-action="Index">
<li><a asp-area="" asp-controller="Role" asp-action="Index">Role</a></li>
</secure-content>
<secure-content asp-area="" asp-controller="Access" asp-action="Index">
<li><a asp-area="" asp-controller="Access" asp-action="Index">Access</a></li>
</secure-content>
</ul
您可以在 github 上找到源代码和演示。
关注点
如果您不想在授权属性中硬编码角色、用户和策略,并且稍后创建角色并分配和编辑角色的访问权限,然后将角色分配给用户,这是一个简单的解决方案。