具有动态角色和动态权限的MVC应用程序具有更改系统中不同功能的授权的能力。
目标
通常对于小型组织,没有预定义的固定角色/用户。他们在成长和繁荣的过程中学习过程。在这种情况下,我们通常会获得创建角色和动态分配权限的要求,而又不损害安全性,因为大多数提出要求的人还不确定角色或策略。因此,在这里,我们将尝试学习基于角色的动态授权。
介绍
在本文中,我们将尝试学习如何创建动态角色以及如何为这些角色动态分配权限。这是前一篇文章“数据库的MVC 6动态导航菜单”的续篇。
以前,我们学习了如何从数据库动态生成菜单。现在,根据该菜单,我们需要验证用户角色的权限。我们将学习:
- 创建一个新角色
- 动态分配/删除角色权限
- 向用户分配/删除新角色
使用的组件
这是构建和测试所提供的演示代码所需的组件。
我们将使用带有C#和MVC项目模板的.NET Core Framework 3.1版,因此让我们开始吧。
在上一篇文章中,我添加了一些额外的字段,例如ExternalUrl
&,DisplayOrder
以提供一个选项,可以在菜单中添加外部链接并根据用户的选择设置菜单项的顺序。
建立新项目
打开Visual Studio 2019,然后单击创建新项目以开始新项目。
它将在下面的屏幕中显示更多选择,因此选择C#,所有平台,Web,然后选择ASP.NET Core Web Application并单击Next。
在这里,我们需要提供项目名称,然后单击Create。
选择.NET Core,ASP.NET Core 3.1,Model-View-Controller作为模板,选择个人用户帐户作为身份验证,然后单击Create
,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; }
}
这里是我的数据库上下文,我们重写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);
}
}
迁移
现在我们需要运行迁移,然后更新数据库,Enable-Migrations
命令已过时,因此我们需要从Migrations
文件夹中删除所有内容,然后运行add migration
命令。
add-migration InitialVersion
这是我的数据库表,如下所示:
新版本的EF .NET Core在OnModelCreating
函数中的ModelBuilder
对象上有HasData
,但现在,在此演示中,我们将继续使用上述方法。
在DbInitializer
中修改,添加了新的权限并分配给了管理员角色,我们需要这些权限在数据库中可用,以便稍后可以分配和验证用户角色。
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();
return result > 0;
}
这是我的Admin
控制器,有关操作的详细实现,我们可以在zip中查看代码。简单的实现,没有魔术代码:)。我们只需要将[Authorize("Authorization")]
放在任何要告诉应用程序验证授权的操作上,或者如果所有操作都受到保护,则可以在控制器级别使用它。
[Authorize("Authorization")]
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;
}
public async Task<IActionResult> Roles() {}
[HttpPost]
public async Task<IActionResult> CreateRole(RoleViewModel viewModel) {}
public async Task<IActionResult> Users() {}
public async Task<IActionResult> EditUser(string id){}
[HttpPost]
public async Task<IActionResult> EditUser(UserViewModel viewModel){}
public async Task<IActionResult> EditRolePermission(string id){}
[HttpPost]
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 User”来运行和测试系统:
- 用户名:admin@test.com
- 密码:P @ ssw0rd
角色,创建角色
以下是在迁移过程中创建的角色列表:
在“创建角色”屏幕中,可以在系统中添加新角色。
分配角色权限
在“角色列表”中,如果单击“编辑权限”按钮,它将带我们进入“权限”屏幕,其中列出了已选中分配的权限的所有权限。
现在,我们可以更改这些权限并进行保存,以使其对该角色下的用户有效。因此,让我们尝试对其进行更改。
我们将取消选中“外部Google链接和创建角色”。
现在,当我保存这些更改,然后再保存该角色的“编辑”权限。
如您所见,现在这两个权限都未选中,并且菜单中也不存在。
现在,我可以尝试通过粘贴URL 来访问“创建角色”页面,因此它应该根据我的更新权限对我进行验证,并向我抛出“访问被拒绝”。
如果我们复制具有访问权限的某个页面的URL,然后与其他无法访问该页面的用户登录并粘贴复制的URL,则可以验证相同的错误。
分配角色给用户
我们可以使用“编辑”按钮查看用户列表。
通过编辑,我们将能够向用户分配/删除角色,单击“编辑”按钮后,我们可以看到以下屏幕,其中包含所有角色的列表作为系统中可用的复选框列表。
因此,现在我们有了基于这些界面的屏幕,用于创建新角色,角色列表,编辑用户,角色的编辑权限,我们需要验证授权。
访问限制
我们将为此目的使用“授权处理程序”,但是在现实世界的系统中,可以更改角色并将其重新分配给不同的用户,或者在一个特定的时间段内,一个用户可以拥有多个角色,而不是在开发时已经定义许多策略或角色。请牢记这一点,我们将给予最终用户自由以授予对其定义的角色的权限,以便具有这些角色的客户/员工可以根据其角色和权限执行其职责。
我们将对AuthorizationHandler
进行泛化,使其在具有数据库权限的情况下动态工作。我们需要创建一个授权需求并从IAuthorizationRequirement
接口继承。现在,我们可以创建AuthorizationHandler
泛型并使用泛型传递我们的要求,然后可以重写该HandleRequirementAsync
函数。从端点获取Controller
和Action
并从数据库中检查权限。通过这种方法,授权将与MVC耦合,但这没关系,因为已针对该特定目的和用途编写了该处理程序。
public class AuthorizationRequirement : IAuthorizationRequirement { }
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);
var isAuthenticated = context.User.Identity.IsAuthenticated;
if (isAuthenticated && _controller != null && _action != null &&
await _dataAccessService.GetMenuItemsAsync(context.User,
_controller.ToString(), _action.ToString()))
{
context.Succeed(requirement);
}
}
}
}
为了演示和限制讨论范围,我们将从数据库中即时检查/验证它,以提高性能,我们可以使用Cache
来保留权限,以减少对每个资源访问进行授权检查的数据库调用。可以将角色权限添加到缓存中的用户声明和权限,以提高性能。
结论
我们已经通过迁移创建了数据库,并在开发环境下启动了我们的项目。登录的用户可以根据动态定义的角色权限查看菜单项和页面。源代码已附加。我鼓励您下载示例代码,然后运行并查看。