MVC .NET Core 动态基于角色的授权
MVC .NET Core 3.1 使用授权处理器和自定义授权策略实现动态基于角色的授权
- 下载 Mvc.RoleAuthorization_with_CustomAuthorizorPolicy_and_Cache.zip - 977.6 KB
- 下载 Mvc.RoleAuthorization_without_Cache_and_CustomAuthorizorPolicy.zip - 976.8 KB
- https://github.com/dnxit/Mvc-Dynamic-Role-Permission-Authorization .Net6
目录
目标
通常,小型组织没有预定义的固定角色/用户。它们随着时间的推移而学习流程并不断发展。在这种情况下,我们通常会收到创建角色并动态分配权限的要求,同时不损害安全性,因为大多数情况下,那些提供需求的人也不确定角色或策略。所以在这里,我们将尝试学习动态的基于角色的授权。
引言
在本文中,我们将尝试学习如何创建动态角色并动态地为这些角色分配权限。这是上一篇文章 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 安全团队之间,仍在进行一些讨论。
- 将 ORing 策略添加为头等公民支持 #1356
- 重新审视 PolicyProvider(大量 # 权限)场景/支持 #917
- 无需使用魔术字符串的授权策略
- 无需使用魔术字符串的授权策略
- 未来 AuthZ 改进列表 #4670
历史
- 2020 年 3 月 5 日:初始版本
- 2022 年 4 月 16 日已将源代码升级到 .Net6 https://github.com/dnxit/Mvc-Dynamic-Role-Permission-Authorization