65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (12投票s)

2018年6月18日

CPOL

3分钟阅读

viewsIcon

54442

您已经了解了基于角色的授权在 ASP.NET Core 中的工作方式,但如果您不想在授权属性中硬编码角色,或者稍后创建角色并指定它可以访问哪个控制器和操作,而无需触及源代码,该怎么办?

引言

您已经了解了基于角色的授权在 ASP.NET Core 中的工作方式。

[Authorize(Roles = "Administrator")]
public class SomeController : Controller
{
}

但是,如果您不想在授权属性中硬编码角色,或者稍后创建角色并指定它可以访问哪个控制器和操作,而无需触及源代码,该怎么办?

Using the Code

让我们开始我们的旅程。创建 ASP.NET Core Web 应用程序项目,并将身份验证更改为“个人用户帐户”。

创建项目后,我们需要做的第一件事是查找项目中的所有控制器。在 *Models* 文件夹中添加两个新类 MvcControllerInfoMvcActionInfo

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 和新的控制器 AccessControllerAccessController 非常简单,没有任何复杂之处。

    将角色分配给用户后,现在我们可以检查用户是否具有访问控制器和操作的权限。添加新文件夹 *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 上找到源代码和演示。

关注点

如果您不想在授权属性中硬编码角色、用户和策略,并且稍后创建角色并分配和编辑角色的访问权限,然后将角色分配给用户,这是一个简单的解决方案。

© . All rights reserved.