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

MVC .NET Core 动态基于角色的授权

starIconstarIconstarIconstarIconstarIcon

5.00/5 (28投票s)

2020年3月5日

CPOL

8分钟阅读

viewsIcon

107311

downloadIcon

5150

MVC .NET Core 3.1 使用授权处理器和自定义授权策略实现动态基于角色的授权

目录

目标

通常,小型组织没有预定义的固定角色/用户。它们随着时间的推移而学习流程并不断发展。在这种情况下,我们通常会收到创建角色并动态分配权限的要求,同时不损害安全性,因为大多数情况下,那些提供需求的人也不确定角色或策略。所以在这里,我们将尝试学习动态的基于角色的授权。

引言

在本文中,我们将尝试学习如何创建动态角色并动态地为这些角色分配权限。这是上一篇文章 MVC 6 从数据库动态生成导航菜单 的延续。

之前,我们学习了如何从数据库动态生成菜单。现在,根据该菜单,我们需要验证用户角色的权限。我们将学习如何

  • 创建新角色
  • 动态分配/删除角色权限
  • 动态分配/删除新角色给用户

使用的组件

以下是构建和测试提供的演示代码所需组件。

我们将使用 .NET Core Framework 3.1 版本,配合 C# 和 MVC 项目模板,让我们开始吧。

在上一篇文章中,我添加了一些额外的字段,如 `ExternalUrl` 和 `DisplayOrder`,以便可以选择在菜单中添加外部链接,并根据用户的选择设置菜单项的顺序。

创建新项目

打开 Visual Studio 2019,点击“创建新项目”开始一个新项目。

这将显示下面的屏幕以进行更多选择,因此请选择 **C#**、**所有平台**、**Web**,然后是 **ASP.NET Core Web Application**,然后点击 **下一步**。

在这里,我们需要提供项目名称,然后点击 **创建**。

选择 **.NET Core**、**ASP.NET Core 3.1**、**Model-View-Controller** 作为模板,选择 **Individual User Accounts** 作为身份验证,然后点击 **创建**,Visual Studio 将为您创建所有这些设置的新项目。

项目设置完成后,让我们根据模型创建数据库,确保在 `appsettings.json` 文件中设置连接字符串。我将使用本地主机作为我的服务器,并使用 Windows 身份验证,我的连接字符串如下。

"DefaultConnection": "Server=localhost;Database=DynamicPermissions;
Trusted_Connection=True;MultipleActiveResultSets=true"

我创建了 `NavigationMenu` 来存储菜单名称,以及 `RoleMenuPermission` 实体来存储角色权限。

[Table(name: "AspNetNavigationMenu")]
public class NavigationMenu
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public Guid Id { get; set; }

    public string Name { get; set; }

    [ForeignKey("ParentNavigationMenu")]
    public Guid? ParentMenuId { get; set; }

    public virtual NavigationMenu ParentNavigationMenu { get; set; }

    public string Area { get; set; }

    public string ControllerName { get; set; }

    public string ActionName { get; set; }
        
    public bool IsExternal { get; set; }

    public string ExternalUrl { get; set; }

    public int DisplayOrder { get; set; }

    [NotMapped]
    public bool Permitted { get; set; }

    public bool Visible { get; set; }
}

[Table(name: "AspNetRoleMenuPermission")]
public class RoleMenuPermission
{
    public string RoleId { get; set; }

    public Guid NavigationMenuId { get; set; }

    public NavigationMenu NavigationMenu { get; set; }
}

这是我的 Db Context,我们重写了 `OnModelCreating` 来定义 `RoleId` 和 `NavigationMenuId` 作为键,因为我们不需要这个表的标识键。

public class ApplicationDbContext : IdentityDbContext
{
      public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
      : base(options)
      {

      }

      public DbSet<RoleMenuPermission> RoleMenuPermission { get; set; }

      public DbSet<NavigationMenu> NavigationMenu { get; set; }

      protected override void OnModelCreating(ModelBuilder builder)
      {
           builder.Entity<RoleMenuPermission>()
           .HasKey(c => new { c.RoleId, c.NavigationMenuId});
           

           base.OnModelCreating(builder);
      }
}

Migrations

现在我们需要运行迁移,然后更新数据库。**Enable-Migrations 命令已被弃用**,所以我们需要删除 `Migrations` 文件夹中的所有内容,然后运行 add migration 命令。

add-migration InitialVersion

我的数据库表如下所示

有关数据播种的更多详细信息,您可以查看以下文章

EF .NET Core 的新版本在 `OnModelCreating` 函数中对 `ModelBuilder` 对象提供了 `HasData`,但目前,我们将在本次演示中坚持使用上述方法。

修改 `DbInitializer`,添加了新的权限并分配给了 Admin 角色,我们需要这些在数据库中可用,以便稍后可以分配和验证用户角色。

为了我们有限的范围,我们将通过种子数据添加,此范围不包含 CRUD 屏幕,但我希望你们这些有才华的人能很快完成。

new NavigationMenu()
{
    Id = new Guid("F704BDFD-D3EA-4A6F-9463-DA47ED3657AB"),
    Name = "External Google Link",
    ControllerName = "",
    ActionName = "",
    IsExternal = true,
    ExternalUrl = "https://www.google.com/",
    ParentMenuId = new Guid("13e2f21a-4283-4ff8-bb7a-096e7b89e0f0"),
    DisplayOrder=2,
    Visible = true,
},
new NavigationMenu()
{
    Id = new Guid("913BF559-DB46-4072-BD01-F73F3C92E5D5"),
    Name = "Create Role",
    ControllerName = "Admin",
    ActionName = "CreateRole",
    ParentMenuId = new Guid("13e2f21a-4283-4ff8-bb7a-096e7b89e0f0"),
    DisplayOrder=3,
    Visible = true,
},
new NavigationMenu()
{
    Id = new Guid("3C1702C5-C34F-4468-B807-3A1D5545F734"),
    Name = "Edit User",
    ControllerName = "Admin",
    ActionName = "EditUser",
    ParentMenuId = new Guid("13e2f21a-4283-4ff8-bb7a-096e7b89e0f0"),
    DisplayOrder=3,
    Visible = false,
},
new NavigationMenu()
{
    Id = new Guid("94C22F11-6DD2-4B9C-95F7-9DD4EA1002E6"),
    Name = "Edit Role Permission",
    ControllerName = "Admin",
    ActionName = "EditRolePermission",
    ParentMenuId = new Guid("13e2f21a-4283-4ff8-bb7a-096e7b89e0f0"),
    DisplayOrder=3,
    Visible = false,
},

我在之前实现的数据服务中添加了两个新函数。

我们将从 `NavigationMenu` 中获取所有已定义的权限,并与具有 `Permitted = true` 的角色进行连接,因此基于此,我们可以渲染勾选/取消勾选的复选框。

public async Task<List<NavigationMenuViewModel>> GetPermissionsByRoleIdAsync(string id)
{
    var items = await (from m in _context.NavigationMenu
                      join rm in _context.RoleMenuPermission
                      on new { X1 = m.Id, X2 = id } equals 
                         new { X1 = rm.NavigationMenuId, X2 = rm.RoleId }
                      into rmp
                      from rm in rmp.DefaultIfEmpty()
                      select new NavigationMenuViewModel()
                      {
                          Id = m.Id,
                          Name = m.Name,
                          Area = m.Area,
                          ActionName = m.ActionName,
                          ControllerName = m.ControllerName,
                          IsExternal = m.IsExternal,
                          ExternalUrl = m.ExternalUrl,
                          DisplayOrder = m.DisplayOrder,
                          ParentMenuId = m.ParentMenuId,
                          Visible = m.Visible,
                          Permitted = rm.RoleId == id
                       })
                         .AsNoTracking()
                         .ToListAsync();

   return items;
}

//Remove old permissions for that role id and assign changed permissions

public async Task<bool> SetPermissionsByRoleIdAsync(string id, IEnumerable<Guid> permissionIds)
{
    var existing = await _context.RoleMenuPermission.Where(x => x.RoleId == id).ToListAsync();
     _context.RemoveRange(existing);

     foreach (var item in permissionIds)
     {
        await _context.RoleMenuPermission.AddAsync(new RoleMenuPermission()
        {
            RoleId = id,
            NavigationMenuId = item,
        });
     }

     var result = await _context.SaveChangesAsync();

     // Remove existing permissions to roles from Cache so it can re evaluate and take effect
     _cache.Remove("RolePermissions");

     return result > 0;
}

这是我的 Admin Controller,有关操作的详细实现,我们可以查看 zip 中的代码。简单的实现,没有魔术代码 :)。我们只需要在任何我们想告诉应用程序验证授权的操作上放置 `[Authorize("Authorization")]`,或者如果所有操作都受到相同策略的保护,也可以在控制器级别使用它。

[Authorize]
public class AdminController : Controller
{
    private readonly UserManager<IdentityUser> _userManager;
    private readonly RoleManager<IdentityRole> _roleManager;
    private readonly IDataAccessService _dataAccessService;
    private readonly ILogger<AdminController> _logger;

    public AdminController(
        UserManager<IdentityUser> userManager,
        RoleManager<IdentityRole> roleManager,
        IDataAccessService dataAccessService,
        ILogger<AdminController> logger)
    {
        _userManager = userManager;
        _roleManager = roleManager;
        _dataAccessService = dataAccessService;
        _logger = logger;
    }

    [Authorize("Authorization")]
    public async Task<IActionResult> Roles() {}

    [HttpPost]
    [Authorize("Roles")]
    public async Task<IActionResult> CreateRole(RoleViewModel viewModel) {}

    [Authorize("Authorization")]
    public async Task<IActionResult> Users() {}

    [Authorize("Users")]
    public async Task<IActionResult> EditUser(string id){}

    [HttpPost]
    [Authorize("Users")]
    public async Task<IActionResult> EditUser(UserViewModel viewModel){}        

    [Authorize("Authorization")]
    public async Task<IActionResult> EditRolePermission(string id){}

    [HttpPost]
    [Authorize("Authorization")]
    public async Task<IActionResult> EditRolePermission
                     (string id, List<NavigationMenuViewModel> viewModel){}
}

这是我们渲染复选框列表的方式。

<form asp-action="EditRolePermission">
    <div class="form-group">
        <ul style="list-style-type: none;">
            @for (var i = 0; i < Model.Count; i++)
            {
                <li>
                    <input type="checkbox" asp-for="@Model[i].Permitted" />
                    <label style="margin-left:10px;" 
                    asp-for="@Model[i].Permitted">@Model[i].Name</label>
                    <input type="hidden" asp-for="@Model[i].Id" />
                    <input type="hidden" asp-for="@Model[i].Name" />
                </li>
            }
        </ul>
    </div>
    <div class="form-group">
        <input type="submit" value="Save" class="btn btn-primary" />
        <a asp-action="Roles">Back to List</a>
    </div>
</form>

所以现在我们可以通过登录管理员用户来运行并测试系统

  • 用户名: admin@test.com
  • 密码: P@ssw0rd

角色,创建角色

以下是作为迁移一部分创建的角色列表

在“创建角色”屏幕中,可以在系统中添加一个新角色。

分配角色权限

在角色列表中,如果我们点击“编辑权限”按钮,它将带我们到权限屏幕,列出所有权限并勾选已分配的权限。

现在我们可以更改这些权限并保存,使其对该角色的用户生效。所以,让我们尝试更改它。

我们将取消勾选“外部 Google 链接”和“创建角色”。

现在当我保存这些更改,然后再次编辑同一角色的权限时。

正如你所见,现在这两个权限已取消勾选,并且也不在菜单中。

现在我可以尝试通过粘贴 URL 来访问“创建角色”页面,因此它应该根据我更新的权限进行验证,并向我抛出“访问被拒绝”的错误。

如果我们将具有访问权限的用户页面的 URL 复制,然后登录到另一个没有该页面访问权限的用户,并粘贴复制的 URL,应该会显示相同的错误。

将角色分配给用户

我们可以看到带有“编辑”按钮的用户列表。

通过编辑,我们将能够将角色分配/删除给用户。点击“编辑”按钮后,我们将看到下面的屏幕,其中包含系统中所有可用角色的复选框列表。

因此,我们现在有了创建新角色、角色列表、编辑用户、编辑角色权限的屏幕。基于这些界面,我们需要验证授权。

带缓存的访问限制

我们将为此目的使用授权处理器,但与在开发时预定义许多策略或角色不同,在真实世界的系统中,角色可以被更改并重新分配给不同的用户,或者一个用户可以在特定时间段内拥有多个角色等。考虑到这一点,我们将赋予最终用户自由,让他们为其定义的角色授予权限,这样其拥有这些角色的客户/员工就可以根据其角色和权限执行其职责。

我们将泛化 `AuthorizationHandler`,使其能够动态地与数据库中的权限一起工作。我们需要创建一个授权要求并继承自 `IAuthorizationRequirement` 接口。然后,我们可以创建一个 `AuthorizationHandler` 并使用泛型传递我们的要求,然后我们可以重写 `HandleRequirementAsync` 函数。以从终结点获取 Controller 和 Action,并从数据库检查权限。通过这种方法,授权将与 MVC 耦合,但这没关系,因为该处理程序是为此特定目的和用途编写的。

当有很多需要保护的 ajax 子操作时,我们可以提供一个父操作名称,例如,我有一个操作“角色列表”,所以我表示如果一个角色有权访问“角色列表”,那么它也应该被允许创建新角色。在这种情况下,我们基于一个权限来保护两个操作。

public class AuthorizationRequirement : IAuthorizationRequirement
{
    public AuthorizationRequirement(string permissionName)
    {
        PermissionName = permissionName;
    }

    public string PermissionName { get; }
}

public class PermissionHandler : AuthorizationHandler<AuthorizationRequirement>
{
    private readonly IDataAccessService _dataAccessService;

    public PermissionHandler(IDataAccessService dataAccessService)
    {
        _dataAccessService = dataAccessService;
    }

    protected async override Task HandleRequirementAsync(AuthorizationHandlerContext context, AuthorizationRequirement requirement)
    {
        if (context.Resource is RouteEndpoint endpoint)
        {
            endpoint.RoutePattern.RequiredValues.TryGetValue("controller", out var _controller);
            endpoint.RoutePattern.RequiredValues.TryGetValue("action", out var _action);
                
            endpoint.RoutePattern.RequiredValues.TryGetValue("page", out var _page);
            endpoint.RoutePattern.RequiredValues.TryGetValue("area", out var _area);

            // Check if a parent action is permitted then it'll allow child without checking for child permissions
            if (!string.IsNullOrWhiteSpace(requirement?.PermissionName) && !requirement.PermissionName.Equals("Authorization"))
            {
                _action = requirement.PermissionName;
            }

            if (context.User.Identity.IsAuthenticated && _controller != null && _action != null &&
                   await _dataAccessService.GetMenuItemsAsync(context.User, _controller.ToString(), _action.ToString()))
            {
                context.Succeed(requirement);
            }
         }

         await Task.CompletedTask;
     }
}

我们可以使用缓存来保存权限,以减少每次访问资源时进行授权检查的数据库调用。可以将角色权限添加到用户声明和缓存中的权限,以提高性能。

所以在数据服务中,我们将做一些更改,我们将使用 MemoryCache,通过 DI 注入,然后使用 GetOrCreateAsync 函数。

public class DataAccessService : IDataAccessService
{
    private readonly IMemoryCache _cache;
    private readonly ApplicationDbContext _context;

    public DataAccessService(ApplicationDbContext context, IMemoryCache cache)
    {
        _cache = cache;
        _context = context;
    }

    public async Task<List<NavigationMenuViewModel>> GetMenuItemsAsync(ClaimsPrincipal principal)
    {
        var isAuthenticated = principal.Identity.IsAuthenticated;
        if (!isAuthenticated)
        {
            return new List<NavigationMenuViewModel>();
        }

        var roleIds = await GetUserRoleIds(principal);

        var permissions = await _cache.GetOrCreateAsync("Permissions",
             async x => await (from menu in _context.NavigationMenu select menu).ToListAsync());

        var rolePermissions = await _cache.GetOrCreateAsync("RolePermissions",
             async x => await (from menu in _context.RoleMenuPermission select menu).Include(x => x.NavigationMenu).ToListAsync());

        var data = (from menu in rolePermissions
                    join p in permissions on menu.NavigationMenuId equals p.Id
                    where roleIds.Contains(menu.RoleId)
                    select p)
                           .Select(m => new NavigationMenuViewModel()
                           {
                              Id = m.Id,
                              Name = m.Name,
                              Area = m.Area,
                              Visible = m.Visible,
                              IsExternal = m.IsExternal,
                              ActionName = m.ActionName,
                              ExternalUrl = m.ExternalUrl,
                              DisplayOrder = m.DisplayOrder,
                              ParentMenuId = m.ParentMenuId,
                              ControllerName = m.ControllerName,
                          }).Distinct().ToList();

        return data;
    }

}

自定义授权策略

通常,当我们有大量策略或动态策略时,在这种情况下,我们无法使用 AuthorizationOptions.AddPolicy 注册每个单独的策略。如果我们要从数据库或外部数据源(如 API)读取这些策略,也可以使用相同的方法。在运行时根据信息创建策略是有意义的。

使用基于策略的授权,策略是通过调用 AuthorizationOptions.AddPolicy 来注册的,作为授权服务配置的一部分。我们可以使用自定义的 IAuthorizationPolicyProvider 来控制如何提供授权策略。所以这是我们的实现类,并且 Startup.cs 中的一些更改也是必需的。

public class AuthorizationPolicyProvider : DefaultAuthorizationPolicyProvider
{
    private readonly AuthorizationOptions _options;

    public AuthorizationPolicyProvider(IOptions<AuthorizationOptions> options) : base(options)
    {
        _options = options.Value;
    }

    public override async Task<AuthorizationPolicy> GetPolicyAsync(string policyName)
    {
        return await base.GetPolicyAsync(policyName)
                 ?? new AuthorizationPolicyBuilder()
                     .AddRequirements(new AuthorizationRequirement(policyName))
                     .Build();
    }
}

在 Startup.cs 中需要将其与处理程序一起注册。

services.AddScoped<IAuthorizationHandler, PermissionHandler>();
services.AddSingleton<IAuthorizationPolicyProvider, AuthorizationPolicyProvider>();

现在我们不再需要在启动类中使用单一策略注册,例如

services.AddAuthorization(options =>
{
     options.AddPolicy("Authorization", policyCorrectUser =>
     {
          policyCorrectUser.Requirements.Add(new AuthorizationRequirement());
     });
});

结论

我们通过迁移创建了数据库,并在开发环境中启动了我们的项目。登录用户可以根据动态定义的角色权限查看菜单项和页面。源代码已附上。我鼓励您下载示例代码,运行并查看。如果您有任何问题/建议,都欢迎您发表评论。

感谢阅读...

值得关注的文章

在开发者社区和 .NET Core 安全团队之间,仍在进行一些讨论。

历史

© . All rights reserved.