ASP.NET Core Razor Pages in Action 10 通过授权控制访问
本章涵盖
• 在 Razor Pages 应用程序中启用授权服务
• 使用角色和声明授权终端节点
• 根据需求和处理程序创建授权策略
• 授权访问资源
在上一章中,您学习了如何通过要求用户对自己进行身份验证来识别用户。通过身份验证后,用户不再是匿名的;他们有一个身份,我们可以使用它来限制对应用程序各个部分的访问。此过程称为授权,对于保护应用程序的某些部分免受不应访问的用户的攻击至关重要。
即使是最简单的动态 Web 应用程序也可能包括一个所有者维护内容的区域,即管理区域。这将需要防止未经授权的访问,除非您希望随机用户开始发布他们自己的内容,或者更糟的是:污损或删除您现有的内容。更复杂的应用程序可能需要复杂的访问策略,其中不同的用户对应用程序的某些部分具有不同级别的权限。例如,您可以允许选定数量的用户添加到您的网站提供的度假地点范围,但进一步限制谁可以管理价格。客户将能够预订假期并查看他们自己的订单的详细信息,但只有管理员才能查看所有订单的详细信息。超级用户可能是唯一可以更改订单部分内容的用户。
在最简单的级别上,您可以保护应用程序的某些部分,以便只有经过身份验证的用户才能访问它们。您将首先学习如何对单个页面或终端节点执行此作。您还将了解如何保护特定文件夹或整个应用程序中的所有页面。然后,您将探索 ASP.NET Core Identity 提供的一些功能,用于更精细地管理授权。您将了解如何根据用户的角色将用户分组在一起,并在此基础上管理对终端节点的访问。然后,您将更详细地探讨上一章中介绍的声明概念,并确定如何基于声明制定授权策略并应用它们来管理对终端节点的访问。阻止访问终端节点称为基于请求的授权。在学习如何创建策略时,您将了解它们如何基于要求及其处理程序。您将编写自己的需求和处理程序,并使用它们来制定一些策略。
最后,您将了解如何在页面中实现 fine-grained 授权。例如,许多用户可能有权访问列出要出租的房产的页面,但只有选定的用户才有权编辑这些房产。在这种情况下,您不希望向没有足够权限的用户显示 Edit 导航。在此类页面中应用授权策略称为基于资源的授权。
我一直认为授权与依赖项注入有一些共同点。这两个主题实际上都是相对容易理解的话题,但都笼罩在抽象概念的迷雾中。正如我希望在有关依赖关系注入的章节中消除迷雾一样,我将首先解释您将使用的有关授权的一些概念。
10.1 Razor Pages 中的基本授权
Razor Pages 应用程序中的授权由许多服务提供,包括 IAuthorizationService。这些必须在应用程序启动时添加到服务容器中。一种便捷的方法 AddAuthorization 负责添加所有必需的服务:
builder.Services.AddAuthorization();
默认情况下,授权中间件在 Web 应用程序模板中通过包含 app 来启用。UseAuthorization() 在 Program 类中。
授权取决于知道用户是谁以及他们试图做什么,以及在基于请求的授权的情况下,他们试图访问哪个页面。如第 4 章所述,由 UseRouting 添加到管道中的 EndpointRouting 中间件负责确定用户尝试访问的页面。在上一章中,您了解了身份验证中间件(使用 UseAuthentication 添加)负责确定用户是谁。最后,MapRazorPages 是执行所选页面的位置。此中间件流程如图 10.1 所示。
图 10.1 中间件授权顺序取决于知道用户是谁以及他们尝试去哪里。如果用户未授权,则管道短路。否则,请求将流向终端节点中间件,并执行页面。
鉴于您不想执行当前用户无权访问的页面,因此管道中放置 UseAuthorization 的唯一逻辑位置是在 UseRouting 和 UseAuthentication 之后以及 MapRazorPages 之前。因此,授权中间件必须放在身份验证中间件之后和调用 MapRazorPages 之前(清单 10.1)。
清单 10.1 app 的位置。UseAuthorization 至关重要
app.UseRouting(); ❶
app.UseAuthentication(); ❶
app.UseAuthorization();
app.MapRazorPages(); ❷
❶ 路由和身份验证中间件必须放在授权中间件之前。
❷ 端点中间件必须放在授权中间件之后。
10.1.1 应用简单授权
您将使用 AuthorizeAttribute 将授权应用于终端节点。该属性具有一些属性,其中包括 Roles 和 Policy,在完成本章时,您将更详细地了解这些属性。在最基本的情况下,当您将属性应用于终端节点时,它会阻止匿名用户访问该终端节点。用户必须进行身份验证才能获得继续的授权。
有多种方法可以将属性应用于终端节点。将其添加到 PageModel 类的最简单方法。
注意熟悉 MVC 框架的读者可能习惯于将 Authorize 属性分配给控制器中的作方法。虽然 PageModel 处理程序方法类似于控制器作方法,但 Razor Pages 不支持将 Authorize 属性分配给处理程序方法。如果您考虑一下,这是有道理的,因为您正在授权终端节点,而不管用于访问它的 HTTP 方法如何。Razor 页面表示单个终结点,而 MVC 控制器通常负责处理多个终结点。
目前,如果通过 GET 请求访问该页面,则 Index 页面会向匿名用户返回 ChallengeResult。您将更改此设置,以便匿名用户无法通过使用 Authorize 属性保护页面来访问该页面。对 Pages\Index.cshtml.cs 中代码的更改将显示在下一个清单中。
清单 10.2 将 Authorize 属性应用于 home PageModel
using CityBreaks.Models;
using CityBreaks.Services;
using Microsoft.AspNetCore.Authorization; ❶
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace CityBreaks.Pages
{
[Authorize] ❷
public class IndexModel : PageModel
{
private readonly ICityService _cityService;
public IndexModel(ICityService cityService) =>
_cityService = cityService;
public List<City> Cities { get; set; }
public async Task OnGetAsync() =>
Cities = await _cityService.GetAllAsync(); ❸
}
}
❶ 添加 using 以引入授权 API。
❷ 将 Authorize 属性添加到 PageModel 类中。
❸ 删除检查以查看用户是否经过身份验证。
启动应用程序时,您将被定向到登录页面,就像以前一样。授权服务能够访问所请求页面的终端节点元数据,并确定它需要授权用户。它还能够确定当前用户未经过身份验证,因此服务本身返回 ChallengeResult(401 状态代码),从而导致用户被定向登录。ChallengeResult 是三种可能的结果之一。如果用户已通过身份验证,但不符合指定的授权策略要求,则授权服务将返回 ForbidResult(403 状态代码)。如果用户获得授权,中间件会将请求传递给管道中的下一个中间件(图 10.2)。
图 10.2 授权中间件中的决策有三种可能的结果之一:401 Challenge、403 Forbidden 或将请求传递给管道中的下一个中间件。
在 Razor Pages 应用程序中,401 响应包括向浏览器发送重定向到登录页面的指令,默认情况下,该页面配置为位于 /identity/ account/login。如果您不使用 Identity,则可以使用 LoginPath cookie 选项对其进行自定义,如上一章所示:
builder.Services.AddAuthentication(CookieAuthenticationDefaults
➥ .AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/Login";
});
当您使用 Identity 时,您可以通过应用程序 Cookie 配置自定义登录路径。此自定义应在将 Identity 服务添加到容器后进行:
builder.Services.ConfigureApplicationCookie(options =>
{
options.LoginPath = "/Login";
});
403 响应包括重定向到由 AccessDeniedPath 选项指定的页面。如果您使用的是 Identity UI,则重定向位置为 /identity/account/accessdenied。由于 KebabPageRouteParameterTransformer 的效果,您需要自定义应用程序中的路径,它在 access 和 denied 之间插入一个连字符。将代码放在 AddDefaultIdentity 后面的 Program.cs 中的下一个列表中。
清单 10.3 自定义 AccessDenied 路径
builder.Services.ConfigureApplicationCookie(options =>
{
options.AccessDeniedPath = "/identity/account/access-denied";
});
通过使用属性装饰 PageModel 来授权端点的声明性方法既快速又简单,但如果您有许多页面要防止匿名用户,则检查是否已将属性应用于相关 PageModel的唯一方法是单独查看每个文件。如果要保护整个文件夹的内容,则必须记住将属性添加到文件夹中的每个页面,包括在将来某个阶段添加的新页面。缓解此问题的一种方法是声明一个派生自 PageModel 的类,并将 Authorize 属性应用于该类,然后获取文件夹中要从该类继承的所有页面。下面说明了如何使用名为 AdminPageModel 的类来实现此目的。
清单 10.4 使用 BasePage 限制对衍生产品的访问
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace CityBreaks.Pages
{
[Authorize]
public class AdminPageModel : PageModel
{
}
}
当您创建需要保护的页面时,只需更改它继承的类型,使其派生自 AdminPageModel 并自动受到 Authorize 属性的保护:
public class CreateModel : AdminPageModel
这种基页方法非常常见,尤其是在旧版本的 .NET 中,但它仍然存在一个问题,即您需要记住更改生成的代码,以确保 Razor 页面派生自自定义类型而不是 PageModel。您始终可以编写有助于实施此目的的单元测试(无论如何,它们都是一个好主意)。
理想情况下,您希望集中将授权应用于终端节点的代码,以便您可以一目了然地了解应用程序的哪些部分受到保护以及受到保护的程度。在第 4 章中,您使用了 PageConventionCollection 类型的一些扩展方法,将新的路由和页面路由模型约定添加到路由系统中。存在其他扩展方法,使您能够通过约定将授权应用于单个页面和整个文件夹。使用这些规则,您可以在一个位置建立授权规则:Program 类。主要方法包括
• AuthorizePage - 向单个页面添加授权
• AuthorizeFolder - 向指定文件夹中的所有页面添加授权
• AuthorizeAreaFolder - 向指定区域内指定文件夹中的所有页面添加授权
这些方法中的每一个都采用页面、文件夹和/或区域的名称,并且还包括采用策略名称的重载。我们稍后会详细探讨政策。目前,您可以将策略视为代表授权要求,而不仅仅是进行身份验证。您可以在 Program.cs 中使用 AuthorizePage 方法替换之前应用于主页的 Authorize 属性。
清单 10.5 使用 AuthorizePage 方法将授权应用于特定页面
builder.Services.AddRazorPages(options => {
options.Conventions.AuthorizePage("/Index"); ❶
options.Conventions.Add(new CultureTemplatePageRouteModelConvention());
options.Conventions.Add(new PageRouteTransformerConvention(new KebabPageRouteParameterTransformer()));
});
❶ 在没有策略的情况下使用 AuthorizePage 与将普通 Authorize 属性应用于端点 PageModel 的效果相同。
您只希望授权用户能够访问 Pages 目录中各种 *Manager 文件夹中的 CRUD 管理页面。您可以使用 AuthorizeFolder 方法立即禁止未经身份验证的用户访问,如下面的清单所示。
示例 10.6 使用 AuthorizeFolder 方法授权文件夹
builder.Services.AddRazorPages(options => {
options.Conventions.AuthorizeFolder("/CityManager");
options.Conventions.AuthorizeFolder("/CountryManager");
options.Conventions.AuthorizeFolder("/PropertyManager");
});
10.1.2 允许匿名访问
在某些情况下,您可能希望禁止匿名访问应用程序的大多数内容,但对奇数页面启用匿名访问。例如,您可能正在开发一个内部业务线应用程序,该应用程序需要锁定以防止匿名用户获得访问权限。为此,您可以指定一个 FallbackPolicy,该 FallbackPolicy 需要在配置授权服务时进行身份验证:
builder.Services.AddAuthorization(options => {
options.FallbackPolicy = new AuthorizationPolicyBuilder().
RequireAuthenticatedUser().Build();
});
FallbackPolicy 在未指定其他授权策略(例如通过属性或约定)的情况下成为应用程序的默认授权策略。
您仍然需要用户能够访问登录页面进行身份验证。对于这些实例,您可以使用 AllowAnonymousAttribute。就像 AuthorizeAttribute 一样,您可以将其应用于目标终端节点的 PageModel 类:
[AllowAnonymous]
public class LoginModel : PageModel
或者,可以将 AuthorizeFolder 方法的应用程序与 AllowAnonymousToPage 方法结合使用,该方法将覆盖指定页面的授权,从而允许用户访问该页面,而无需进行身份验证。
清单 10.7 允许匿名访问单个端点
builder.Services.AddRazorPages(options => {
options.Conventions.AuthorizeFolder("/"); ❶
options.Conventions.AllowAnonymousToPage("/Login"); ❷
});
❶ 默认阻止匿名访问应用程序。
❷ 允许匿名访问受保护文件夹中的登录页面。
到目前为止,我们已经研究了完全取决于用户是否经过身份验证的授权。在现实世界中,除了最简单的应用程序之外,其他任何内容都需要更精细的访问控制。在本章的其余部分,我们将探索表达这些需求并将其应用于用户的策略。
10.2 使用角色
角色提供了一种简单的机制,用于将具有相同访问级别的用户分组在一起。它们在复杂性不太可能增加且易于区分不同用户组的访问需求的应用程序中最有用。Identity 包括对角色的支持,但必须通过使用 AddRoles<TRole>
方法将与角色相关的服务添加到容器中来启用它。IdentityRole 是表示角色的默认实现:
builder.Services.AddDefaultIdentity<CityBreaksUser>(options => {
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
})
.AddRoles<IdentityRole>() ❶
.AddEntityFrameworkStores<CityBreaksContext>();
❶ 使用 AddRoles 方法将与角色相关的服务添加到应用程序。
没有用于管理角色的 UI,因此您必须构建自己的 UI。用于处理角色的主要 API 是 RoleManager<TRole>
服务,它是通过 AddRoles 方法添加到服务容器的服务之一。RoleManager 包括以下 CRUD 方法:
• CreateAsync
• UpdateAsync
• 删除异步
RoleManager 还包括一个属性 Roles,该属性返回 RoleStore 中的所有角色。RoleStore 表示角色的存储机制。在你的例子中,那就是 SQLite 数据库。通常,您不会直接使用 RoleStore;您将为此使用 RoleManager。在接下来的几节中,您将了解如何创建一个简单的管理区域,用于使用 RoleManager 创建和查看角色并将其分配给用户。
10.2.1 查看角色
首先,将名为 RolesManager 的文件夹添加到 Pages 文件夹。在该页面中,添加一个名为 Index.cshtml 的新 Razor 页面。将 RoleManager 服务注入 PageModel 类构造函数,并使用其 Roles 属性填充公共 List
清单 10.8 RolesManager IndexModel 类
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace CityBreaks.Pages.RolesManager
{
public class IndexModel : PageModel
{
private readonly RoleManager<IdentityRole> _roleManager; ❶
public IndexModel(RoleManager<IdentityRole> roleManager) ❶
{ ❶
_roleManager = roleManager; ❶
} ❶
public List<IdentityRole> Roles { get; set; } ❷
public void OnGet()
{
Roles = _roleManager.Roles.ToList(); ❷
}
}
}
❶ 通过构造函数将 RoleManager<TRole>
服务注入到 IndexModel 中,并将其分配给私有字段供以后使用。
❷ 声明一个公共 List<IdentityRole>
属性,并使用 RoleManager 服务将其填充到所有现有角色中。
在 Razor 页面本身中,检查是否有任何角色,如果有,请在表中显示这些角色。此代码如下面的清单所示。
清单 10.9 列出角色并在表中显示它们
@page
@model CityBreaks.Pages.RolesManager.IndexModel
@{
ViewData["Title"] = "Roles";
}
<a asp-page="/RolesManager/Create">New</a>
@if (Model.Roles.Any())
{
<table class="table">
@foreach(var role in Model.Roles)
{
<tr>
<td>@role.Name</td>
</tr>
}
</table>
}
这里的代码现在应该不需要任何解释。如果您运行该页面,则只会看到用于创建新角色的链接。它无处可去,因为该页面尚不存在。那是你的下一份工作。
10.2.2 添加角色
将名为 Create 的新 Razor 页面添加到 RolesManager 文件夹。这将包含用于创建新角色的表单。该角色唯一需要的数据是名称。将 RoleManager 服务注入页面,并使用其 CreateAsync 方法添加新角色。同样,代码应类似于您已经创建的 CRUD 页面。下面的清单显示了 PageModel 类的代码。
清单 10.10 RolesManager 的 CreateModel
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace CityBreaks.Pages.RolesManager
{
public class CreateModel : PageModel
{
private readonly RoleManager<IdentityRole> _roleManager;
public CreateModel(RoleManager<IdentityRole> roleManager)
{
_roleManager = roleManager;
}
[BindProperty]
public string Name { get; set; }
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
var role = new IdentityRole { Name = Name };
await _roleManager.CreateAsync(role);
return RedirectToPage("/RolesManager/Index");
}
return Page();
}
}
}
接下来,将表单添加到 Razor 页面本身。
清单 10.11 创建角色表单
@page
@model CityBreaks.Pages.RolesManager.CreateModel
@{
ViewData["Title"] = "Create Role";
}
<h4>Create Role</h4>
<div class="row">
<div class="col-md-8">
<form method="post">
<div class="form-group mb-3">
<label asp-for="Name" class="control-label"></label>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="form-group">
<input type="submit" value="Assign" class="btn btn-primary" />
</div>
</form>
</div>
</div>
@section scripts{
<partial name="_ValidationScriptsPartial" />
}
现在运行应用程序,并导航到 /roles-manager /create。添加一个名为 Admin 的角色,当您满意它有效时,再添加两个名为 CityAdmin 和 PropertyAdmin 的角色。
10.2.3 为用户分配角色
在为用户分配角色之前,您需要一些用户。在应用程序中注册三个用户,使用以下电子邮件地址和相同的密码(为简单起见)。我在本章随附的代码下载中使用了 password:
anna@test.com
colin@test.com
paul@test.com
接下来,将新的 Razor 页面添加到名为 Assign 的 RolesManager 文件夹。在本页中,您将获取所有用户的列表和所有角色的列表,并将它们显示在用于将所选用户分配给所选角色的选定列表中。将 AssignModel 代码更改为以下清单中的代码。
清单 10.12 AssignModel 代码
using CityBreaks.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
namespace CityBreaks.Pages.RolesManager
{
public class AssignModel : PageModel
{
private readonly RoleManager<IdentityRole> _roleManager; ❶
private readonly UserManager<CityBreaksUser> _userManager; ❶
public AssignModel(RoleManager<IdentityRole> ❶
➥ roleManager, UserManager<CityBreaksUser> userManager) ❶
{ ❶
_roleManager = roleManager; ❶
_userManager = userManager; ❶
} ❶
public SelectList Roles { get; set; }
public SelectList Users { get; set; }
[BindProperty, Required, Display(Name ="Role")]
public string SelectedRole { get; set; }
[BindProperty, Required, Display(Name ="User")]
public string SelectedUser { get; set; }
public async Task OnGet()
{
await GetOptions();
}
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
var user = await _userManager.FindByNameAsync(SelectedUser);❷
await _userManager.AddToRoleAsync(user, SelectedRole); ❸
return RedirectToPage("/RolesManager/Index");
}
await GetOptions();
return Page();
}
public async Task GetOptions() ❹
{ ❹
var roles = await _roleManager.Roles.ToListAsync(); ❹
var users = await _userManager.Users.ToListAsync(); ❹
Roles = new SelectList(roles, nameof(IdentityRole.Name)); ❹
Users = new SelectList(users, nameof(CityBreaksUser.UserName));❹
} ❹
}
}
❶ 将 UserManager 和 RoleManager 服务注入 PageModel 类。
❷ 获取具有所选名称的用户。
❸ 将所选用户分配给所选角色。
❹ 声明一个私有方法,将用户和角色分配给 SelectList 对象。
Razor 页面本身在表单中包含两个 select 元素,如下面的清单所示。
示例 10.13 将用户分配给 Role 表单
@page
@model CityBreaks.Pages.RolesManager.AssignModel
@{
}
<h4>Assign User To Role</h4>
<div class="row">
<div class="col-md-8">
<form method="post">
<div class="form-group mb-3">
<label asp-for="SelectedUser" class="control-label"></label>
<select asp-for="SelectedUser" asp-items="Model.Users"
➥ class="form-control">
<option></option>
</select>
<span asp-validation-for="SelectedUser"
➥ class="text-danger"></span>
</div>
<div class="form-group mb-3">
<label asp-for="SelectedRole" class="control-label"></label>
<select asp-for="SelectedRole"
➥ asp-items="Model.Roles" class="form-control">
<option></option>
</select>
<span asp-validation-for="SelectedRole"
➥ class="text-danger"></span>
</div>
<div class="form-group">
<input type="submit" value="Assign"
➥ class="btn btn-primary" />
</div>
</form>
</div>
</div>
@section scripts{
<partial name="_ValidationScriptsPartial" />
}
完成此作后,启动应用程序,并导航到 /roles-manager/assign。将 anna@test.com 分配给 Admin 角色。完成此作后,返回到 Assign .cshtml 文件,并为 Microsoft.AspNetCore.Authorization 添加 using 指令。然后向 AssignModel 类添加 Authorize 属性,但这次将 “Admin” 分配为 Roles 属性中的值:
[Authorize(Roles = "Admin")]
public class AssignModel : PageModel
{
重新运行应用程序,这一次,使用 colin@test.com 或 paul@test.com 登录。然后尝试导航到 /roles-manager /assign。您应该会发现您被重定向到 Access Denied 页面。
注销,然后使用 anna@test.com 登录。这一次,当您导航到 roles-manager /assign 时,您应该会访问该页面。Anna 可以访问该页面,因为她是 Roles 属性中指定的角色的成员,而 Paul 则不是。尽管您只传入了一个角色名称,但 Roles 属性采用逗号分隔的角色名称列表,这在使用角色时提供了灵活性。此外,用户可以属于多个角色。
10.2.4 使用策略应用角色检查
您扩展了 Authorize 属性以检查当前用户是否属于指定角色。如果要对整个文件夹的内容应用此检查,可以使用采用策略的 AuthorizeFolder 方法的重载;我们很快会更详细地研究政策。不过,您可以将策略视为表示需要满足的要求,以确定当前用户是否有权访问请求的终端节点。
对于相对简单的策略,您可以在 AddAuthorization 方法中使用 AuthorizeOptions 来配置基于角色的策略。AddPolicy 方法采用策略的名称和 AuthorizationPolicyBuilder,后者具有 RequireRole 方法,使您能够声明需要哪些角色。
清单 10.14 在 Program.cs 中配置基于角色的策略
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminPolicy",
➥ policyBuilder => policyBuilder.RequireRole("Admin"));
});
配置名为 AdminPolicy 的策略后,您可以将其应用于 AuthorizeFolder 方法,以确保只有 Admin 角色的成员才能访问内容:
builder.Services.AddRazorPages(options => {
options.Conventions.AuthorizeFolder("/RolesManager", "AdminPolicy");
});
正如我在本节开头所指出的,角色对于相对简单的授权要求非常有用,其中访问策略可以应用于用户组。用户要么是角色的成员,要么不是。角色的优点是易于配置和管理。
如果您的应用程序的授权要求越来越复杂,您将开始发现越来越复杂的角色集合难以管理。此时,您需要根据对单个用户的了解来控制授权,而不是基于预定义的用户组来管理授权。您将用于确定这一点的机制称为 claims。
10.3 基于声明的授权
我们在上一章中谈到了声明,但作为复习,声明只是名称-值对,表示您了解的有关用户的数据项。它们附加到 ClaimsIdentity,然后附加到 ClaimsPrincipal(请参见图 10.3)。
图 10.3 ClaimsPrincipal 支持多个标识,每个标识支持多个声明。
在 .NET 中,声明由 Claim 类表示。其属性包括 Type、Value 和 Issuer。最后一个 (Issuer) 是颁发声明的颁发机构。在应用程序中将声明分配给用户时,默认情况下,颁发者为 LOCAL_AUTHORITY。如果您在应用程序中合并了外部身份验证提供程序(如 Google 或 Facebook),它们将颁发他们添加到他们身份验证的身份的任何声明。您可以根据为颁发者提供的权重来选择要使用的声明版本。例如,像 Facebook 这样的外部身份验证服务很可能会证明电子邮件声明,但电子邮件地址可能不存在。
Type 表示的内容没有限制。广泛使用的声明类型由域 schemas.xmlsoap.org 中的 URI 表示。期望开发人员在其代码中使用这些 URI 是不合理的,因此为方便起见,它们在 .NET 中由 ClaimTypes 类中的常量集合表示。表 10.1 显示了您最有可能使用的索赔类型。
表 10.1 常用的声明类型
当用户在应用程序中进行身份验证并为其分配了 ClaimsIdentity 时,身份验证系统会向其添加各种声明。这些包括 Name、Email 和 NameIdentifier(图 10.4)。
图 10.4 已验证用户的填充声明
如果要使用声明作为管理授权的基础,则需要某种方法来分配与访问级别相关的其他声明,并根据用户拥有的声明的存在或值来测试用户是否符合条件。在下一节中,您将创建一个简单的页面,用于向用户添加新声明。
10.3.1 向用户添加声明
首先,您将向 Pages 文件夹添加一个名为 ClaimsManager 的新文件夹。在该页面中,您将添加一个名为 Index 的新 Razor 页面。这将列出已分配其他声明的所有用户,以及已分配的声明的详细信息。
ClaimsManager 索引页面的 PageModel 代码将 UserManager 作为注入的依赖项,并将其分配给公共属性,以便页面的 Razor 部分可以通过其 Model 属性访问它。它还在 OnGetAsync 方法中用于填充 CityBreaksUser 对象的集合。
清单 10.15 应用程序的 claims manager 部分的 IndexModel 类
using CityBreaks.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace CityBreaks.Pages.ClaimsManager
{
public class IndexModel : PageModel
{
public UserManager<CityBreaksUser> UserManager { get; set; }
public IndexModel(UserManager<CityBreaksUser> userManager)
{
UserManager = userManager;
}
public List<CityBreaksUser> Users { get; set; }
public async Task OnGetAsync()
{
Users = await UserManager.Users.ToListAsync();
}
}
}
Razor 页面循环访问用户集合,并通过 UserManager.GetClaimsAsync 方法获取其声明。此方法与数据存储通信,因此它仅返回已存储在 AspNetUserClaims 表内的数据库中的声明。生成的数据不包括由身份验证服务等分配的声明。如果找到任何存储的声明,则会在屏幕上呈现它们的详细信息。
清单 10.16 ClaimsManager 索引页面
@page
@model CityBreaks.Pages.ClaimsManager.IndexModel
@{
ViewData["Title"] = "User Claims";
}
<h4>User Claims</h4>
<a class="btn btn-success" asp-page="/ClaimsManager/Assign">New</a>
@foreach (var user in Model.Users)
{
var claims = await Model.UserManager.GetClaimsAsync(user);
if (claims.Any())
{
<h5>@user.UserName</h5>
<table class="table-striped col-12">
<tr>
<th>Type</th>
<th>Value</th>
<th>Issuer</th>
</tr>
@foreach (var claim in claims)
{
<tr>
<td>@claim.Type</td>
<td>@claim.Value</td>
<td>@claim.Issuer</td>
</tr>
}
</table>
}
}
值得注意的是,前面的代码包含一些您应该避免用于生产应用程序的内容。这是 N + 1 问题的一个示例,之所以这样称呼,是因为代码对用户进行一次数据库调用(在 OnGetAsync 方法中),然后对数据库进行 N 次进一步调用,其中 N 表示在第一次调用中检索到的结果数。每次执行 GetClaimsAsync 时,都会发出一个数据库查询。根据您的应用程序,如果您有大量数据和/或并发用户,这可能会严重损害性能。如果您发现自己需要迭代所有用户的声明,您应该考虑编写自己的 SQL 以在一次调用中获取所有相关数据。
首次运行此页面时,将进行所有数据库调用,但没有要显示的数据,因此您只会看到邀请您添加新声明的按钮。目前它无处可去,因为您尚未创建页面。
将名为 Assign 的新页面添加到 ClaimsManager 文件夹中。此页面将提供选择列表中的用户列表以及声明类型和值的输入。您将使用此页面创建声明并将其分配给用户。UserManager 被注入到 AssignModel 构造函数中,并用于填充包含每个用户的 Id 和名称的 SelectList。所选内容将绑定到 SelectedUserId 属性。添加了两个进一步的绑定属性,表示声明类型和值。
清单 10.17 用于向用户添加声明的 AssignModel 代码
using CityBreaks.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
namespace CityBreaks.Pages.ClaimsManager
{
public class AssignModel : PageModel
{
private readonly UserManager<CityBreaksUser> _userManager;
public AssignModel(UserManager<CityBreaksUser> userManager)
{
_userManager = userManager;
}
public SelectList Users { get; set; }
[BindProperty, Required, Display(Name = "User")]
public string SelectedUserId { get; set; }
[BindProperty, Required, Display(Name ="Claim Type")]
public string ClaimType { get; set; }
[BindProperty, Display(Name = "Claim Value")]
public string ClaimValue { get; set; }
public async Task OnGetAsync()
{
await GetOptions();
}
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
var claim = new Claim(ClaimType, ClaimValue ?? String.Empty);
var user = await _userManager.FindByIdAsync(SelectedUserId);
await _userManager.AddClaimAsync(user, claim);
return RedirectToPage("/ClaimsManager/Index");
}
await GetOptions();
return Page();
}
public async Task GetOptions()
{
var users = await _userManager.Users.ToListAsync();
Users = new SelectList(users,
➥ nameof(CityBreaksUser.Id), nameof(CityBreaksUser.UserName));
}
}
}
使用 UserManager 的 AddClaimAsync 方法将其他声明分配给用户。它采用 user 和 claim 作为参数。您可以使用 bound 属性来构造这些属性。这一次,由于您将用户的唯一标识符绑定到选择列表,因此您使用 FindByIdAsync 方法从 UserManager 获取用户。请记住,您之前使用过 FindByNameAsync。您根据传入的类型和值构造新声明。声明不必分配值,但该值不能为 null,因此如果未提供值,则传入 String.Empty。添加声明后,您将被定向到 Index 页面,您可以在其中看到显示的新声明。
运行应用程序,并导航到 /claims-manager /assign。向 anna@test.com 添加新声明。type 应该是 Admin,值应该留空(图 10.5)。分配声明后,下一步是将其用作授权策略的一部分。
图 10.5 分配给用户的声明列表
10.3.2 使用策略强制实施基于声明的授权
基于声明的授权依赖于策略,我之前已经提到过。策略由一个或多个要求组成。当策略中的所有要求都得到满足时,将授予授权。一个或多个授权处理程序的工作是评估策略中的每个需求是否得到满足(图 10.6)。
图 10.6 策略由一个或多个需求组成,每个需求都有一个或多个处理程序。
使用此模式,可以构建复杂的授权策略,从而对谁可以访问应用程序的哪些部分进行精细控制。除了保护端点之外,还可以在 Razor 页面本身内应用授权策略,因此,例如,您可以根据当前用户的声明切换 UI 部分的可见性。
之前使用 AuthorizationPolicyBuilder.RequireRole 方法时,将创建一个 RolesAuthorizationRequirement 类型的要求,该要求指定需要指定的角色。AuthorizationPolicyBuilder上提供了其他方法,这些方法使您只需使用其他内置要求和处理程序即可表达通用策略(表 10.2)。
表 10.2 构建简单策略的常用方法
RequireClaim 方法的变体创建一个 ClaimsAuthorizationRequirement,其中包含一个处理程序,如果指定的声明存在,则返回 true,如果指定了值,则至少找到其中一个值。您可以通过更改现有策略的代码以使用 RequireClaim 方法而不是 RequireRole 来测试这一点:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminPolicy", policyBuilder =>
➥ policyBuilder.RequireClaim("Admin"));
});
您还可以通过将策略名称传递给 AuthorizeAttribute 的 Policy 属性,将此策略单独应用于页面:
[Authorize(Policy="AdminPolicy")]
public IndexModel : PageModel
{
...
}
10.3.3 将断言用于更复杂的要求
提供 RequireAssertion 方法是为了处理比其他方法所能处理的更复杂的要求。例如,假设您要实施一项要求,规定如果用户具有具有特定值的声明,则用户可以访问角色管理区域,但前提是他们已经在公司工作了六个月以上。为了能够确定这一点,您需要将用户的加入日期记录为索赔。然后,您需要将该值转换为 DateTime 并将其与当前日期进行比较,以确定用户在企业工作的时间。
为了证明这一点,请检查图 10.7 中所示的声明。请注意,在撰写本文时,Paul 的加入日期不到 6 个月,而其他用户的加入日期则超过 6 个月。如果要复制此练习,请务必输入满足相同条件的日期。只有 Anna 和 Paul 具有值为 View Roles 的 Permission 声明。
图 10.7 分配给用户的加入日期和权限声明
RequireAssertion 方法将 AuthorizationHandlerContext 作为参数,如清单 10.18 所示。此类型通过其 User 属性提供对当前用户的访问权限。从那里,您可以检查他们的主张。在下面的代码中,确保用户具有名为 Permission 的声明,并且其值为 View Roles。然后,您尝试检索名为 Joining Date 的声明的值并将其转换为 DateTime,以便您可以根据授权要求测试其值。
Listing 10.18 RequireAssertion 用于更复杂的授权需求
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ViewRolesPolicy", policyBuilder =>
policyBuilder.RequireAssertion(context => ❶
{
var joiningDateClaim = context.User.FindFirst(c => ❷
➥ c.Type == "Joining Date")?.Value; ❷
var joiningDate = Convert.ToDateTime( ❷
➥ joiningDateClaim); ❷
return context.User.HasClaim("Permission",
➥ "View Roles") && ❸
joiningDate > DateTime.MinValue && ❹
joiningDate < DateTime.Now.AddMonths(-6); ❹
}));
});
❶ 使用 RequireAssertion 方法,该方法将 AuthorizationHandlerContext 作为参数,为当前用户提供访问权限。
❷ 使用 FindFirst 方法访问声明并获取其值(如果有)并将其转换为 DateTime。
❸ 使用 HasClaim 方法确定存在具有指定值的声明。
❹ 将联接日期值与 DateTime.MinValue 与当前日期进行比较,以确保声明不为 null,并且日期早于 6 个月前。
内置要求和处理程序应涵盖最常见的授权要求。如果你有很多复杂的授权策略要应用于你的应用程序,你的 Program 类将很快被多个断言填满。此时,您可以通过编写自己的自定义要求和处理程序,将代码从授权配置中移出并移动到单独的类中。
10.3.4 自定义授权要求和处理程序
授权要求类实现 IAuthorizationRequirement 接口。它是一个空的标记接口,没有定义任何成员。处理程序由 IAuthorizationHandler 接口表示,该接口定义 HandleAsync 方法,该方法将 AuthorizationHandlerContext 对象作为参数并返回 Task。处理需求的逻辑位于此方法中。
要求可以有多个处理程序,但是如果要求和处理程序之间存在一对一的关系,则通常会看到两者的代码放在实现两个接口的同一类中。以下示例说明了如何将您之前创建的断言迁移到此类中,该类采用表示月数的参数。将名为 AuthorizationRequired 的新文件夹添加到项目中,然后使用下面清单中的代码添加一个名为 ViewRolesRequirement 的新类。
Listing 10.19 带有内置 handler 的自定义需求
using Microsoft.AspNetCore.Authorization;
namespace CityBreaks.AuthorizationRequirements
{
public class ViewRolesRequirement :
➥ IAuthorizationRequirement, IAuthorizationHandler ❶
{
public int Months { get; } ❷
public ViewRolesRequirement(int months) ❷
{ ❷
Months = months > 0 ? 0 : months; ❷
} ❷
public Task HandleAsync( ❸
➥ AuthorizationHandlerContext context) ❸
{
var joiningDateClaim = context.User. ❹
➥ FindFirst(c => c.Type == "Joining Date")?.Value; ❹
if(joiningDateClaim == null) ❹
{ ❹
return Task.CompletedTask; ❹
} ❹
var joiningDate = Convert.ToDateTime( ❺
➥ joiningDateClaim); ❺
if(context.User.HasClaim("Permission", ❺
➥ "View Roles") && ❺
joiningDate > DateTime.MinValue && ❺
joiningDate < DateTime.Now.AddMonths( ❺
➥ Months)) ❺
{ ❺
context.Succeed(this); ❺
} ❺
return Task.CompletedTask; ❻
}
}
}
❶ 该类同时实现 IAuthorizationRequirement 和 IAuthorizationHandler 接口。
❷ 构造函数将 int 作为参数,并确保它不是正数。
❸ HandleAsync 方法是根据 IAuthorization-Handler 接口的要求实现的。
❹ 检查用户以查看他们是否具有 joiningDateClaim。否则,将退出处理程序。
❺ 评估加入日期以查看它是否存在,以及其值是否早于传入的年龄。
❻ 如果不满足要求,则返回 Task.CompletedTask 以满足 HandleAsync 方法签名。
如果标记为已成功评估,则满足该要求。这是通过调用 AuthorizationHandlerContext 类的 Succeed 方法实现的。此类还提供 Fail 方法,您可以调用该方法以确保授权不成功。例如,如果您的处理程序允许除满足指定条件的用户以外的所有用户,则可以使用此方法。您可以使用 PolicyBuilder 注册策略,为 months 参数传入合适的值。
清单 10.20 注册自定义需求
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ViewRolesPolicy", policyBuilder =>
policyBuilder.AddRequirements(new ViewRolesRequirement(months: -6)));
});
创建单独的处理程序类
对于简单的用例,构建组合的需求和处理程序很好,但更常见的是,您可能希望将处理程序创建为单独的类以实现重用。执行此作时,需要对常规方法进行一些更改。现有要求本身被缩减为仅 IAuthorizationRequirement 的实现。
Listing 10.21 独立需求类
public class ViewRolesRequirement : IAuthorizationRequirement
{
public int Months { get; }
public ViewRolesRequirement(int months)
{
Months = months > 0 ? 0 : months;
}
}
创建一个名为 AuthorizationHandlers 的新文件夹,并向其添加一个名为 ViewRolesHandler 的新类。这是单独的 handler 类。它实现 IAuthorizationHandler。由于处理程序类本身的范围不限于特定要求,因此您必须访问 AuthorizationHandlerContext 的 PendingRequirements 属性,以筛选出正确的要求类型。PendingRequirements 属性获取尚未标记为成功的所有要求。
清单 10.22 被分离到一个类中的 ViewRolesHandler
public class ViewRolesHandler : IAuthorizationHandler
{
public Task HandleAsync(AuthorizationHandlerContext context)
{
foreach (var requirement in ❶
➥ context.PendingRequirements.ToList()) ❶
{
if (requirement is ViewRolesRequirement req) ❷
{
var joiningDateClaim =
➥ context.User.FindFirst(c => c.Type ==
➥ "Joining Date")?.Value;
if (joiningDateClaim == null)
{
return Task.CompletedTask;
}
var joiningDate = Convert.ToDateTime(joiningDateClaim);
if (context.User.HasClaim("Permission", "View Roles") &&
joiningDate < DateTime.Now.AddMonths(req.Months))
{
context.Succeed(requirement);
}
}
}
return Task.CompletedTask;
}
}
❶ PendingRequirements 返回所有未满足的要求。需要将它们分配给一个列表,以便您可以对它们执行作。
❷ 使用模式匹配来识别 ViewRolesRequirements 并将它们分配给局部变量。
此方法与组合需求处理程序之间的最后一个区别是,您需要将处理程序注册到 Service Container 中,作为 IAuthorizationHandler 的实现:
builder.Services.AddSingleton<IAuthorizationHandler, ViewRolesHandler>();
完成此作后,授权策略的工作方式与之前采用组合 requirement-handler 组合的方法相同。
对需求使用多个处理程序
正如我之前提到的,需求可以有多个处理程序。当有其他方法可以满足要求时,通常是这种情况。假设用户可以查看角色,前提是他们具有值为 View Roles 的 Permission 声明,并且已在公司工作至少 6 个月,或者他们处于 Admin 角色中。现在,您有两种替代方法来授权用户。您可以向现有处理程序添加更多代码以检查用户是否在指定的角色中,但随着时间的推移,随着更多替代方案的出现,该代码可能会变得一团糟。相反,您将为 ViewRolesRequirements 实现一个额外的处理程序。但是,这一次,您将采用另一种方法来制作处理程序,该处理程序专门针对需求进行类型化,因此无需过滤所有待处理的需求。
您将从抽象 AuthorizationHandler<TRequirement>
类派生,其中 TRequirement 表示处理程序所针对的需求类型。处理程序逻辑放置在重写的 HandleRequirementAsync 方法中,该方法除了 AuthorizationHandlerContext 之外,还采用要求类型作为参数。下面的清单显示了重构为名为 HasClaimHandler 的 AuthorizationHandler<TRequirement>
类的声明检查,该类位于名为 AuthorizationHandlers 的文件夹中。
清单 10.23 处理授权需求
using CityBreaks.AuthorizationRequirements;
using Microsoft.AspNetCore.Authorization;
namespace CityBreaks.AuthorizationHandlers
{
public class HasClaimHandler : AuthorizationHandler<ViewRolesRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
ViewRolesRequirement req)
{
var joiningDateClaim =
➥ context.User.FindFirst(c => c.Type == "Joining Date")?.Value;
if (joiningDateClaim == null)
{
return Task.CompletedTask;
}
var joiningDate = Convert.ToDateTime(joiningDateClaim);
if (context.User.HasClaim("Permission", "View Roles") &&
joiningDate < DateTime.Now.AddMonths(req.Months))
{
context.Succeed(req);
}
return Task.CompletedTask;
}
}
}
接下来,添加一个名为 IsInRoleHandler 的新类,该类负责处理授权的附加条件。该代码与前面的处理程序非常相似。
清单 10.24 IsInRoleHandler
using CityBreaks.AuthorizationRequirements;
using Microsoft.AspNetCore.Authorization;
namespace CityBreaks.AuthorizationHandlers
{
public class IsInRoleHandler : AuthorizationHandler<ViewRolesRequirement>
{
protected override Task
➥ HandleRequirementAsync(AuthorizationHandlerContext context,
ViewRolesRequirement req)
{
if (context.User.IsInRole("Admin"))
{
context.Succeed(req);
}
return Task.CompletedTask;
}
}
}
这两个处理程序都需要向服务容器注册:
builder.Services.AddSingleton<IAuthorizationHandler, IsInRoleHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, HasClaimHandler>();
这些注册应替换现有的处理程序注册;否则,它也将被评估。实现 IAuthorizationHandler 的处理程序将针对每个要求进行处理,而类型化为特定要求的处理程序仅针对该类型的要求执行。
要测试此新安排,请从 Anna 中删除 Permission 声明,以便她仅具有 Admin 角色和 Joining date 声明。您可以直接使用数据库管理工具执行此作。然后运行应用程序,并以她的身份登录。您应该会发现她仍然能够访问 Roles 文件夹内容。
10.3.5 角色还是声明?
我们探索了两种方法来确定用户是否获得授权:基于他们所处的角色或他们拥有的声明。但是您应该选择哪种方法呢?自 Identity 发布之前,角色就一直是 ASP.NET 的一部分,当时用户管理系统围绕成员资格框架展开。它们是一个非常简单易掌握的概念,但它们旨在与用户组一起使用,并旨在表示这些用户组可以执行的作。引入 Identity 时,重点转移到基于声明的声明。声明描述的是用户是什么,而不是他们可以做什么。甚至您一直在使用的 IsInRole 方法也会检查特定声明。
一般的建议是支持索赔而不是角色。声明比角色提供更大的灵活性,角色主要出于向后兼容性的原因包含在 Identity 中。话虽如此,至少有一个领域角色仍然可以提供价值:作为向用户批量分配声明的机制。
我们在上一章中查看了 Identity 表的架构。我们当时没有探索的已创建表之一是 AspNetRoleClaims 表。此表包含已分配给角色(而不是单个用户)的声明及其值。置于指定角色中的任何用户都会自动获取所有相关声明。
RoleManager 类具有一个 AddClaimsAsync 方法,该方法将 IdentityRole 对象和 Claim 作为参数,并将它们添加到此表中。使用现有的 ClaimsManager/Assign 页面作为基础,创建一个名为 AssignToRole 的附加页面,该页面使用此方法将声明添加到角色。原始 Assign 页面与此新页面之间的主要区别在于,新页面将 RoleManager
10.4 授权资源
到目前为止,我们专注于授权端点 (页面)。用户要么有权访问终端节点,要么没有。有时,您需要根据终端节点公开的资源执行授权。例如,您可能遇到这样的情况:任何授权用户都可以添加新属性(资源),但只有创建者可以编辑属性。这意味着您和我都可以添加属性,但您不能编辑我的属性,我也无法编辑您的属性。要应用此类授权,您需要知道该资产的创建者。这只能通过执行端点并从数据库加载属性来完成。在本节中,您将了解如何使用将资源作为参数的授权处理程序来处理此任务。
在执行此作之前,您需要将创建者分配给属性。打开 Property 类,并添加以下两个属性:
public string CreatorId { get; set; }
public CityBreaksUser Creator { get; set; }
CreatorId 是一个字符串属性,因为它将保存用户的 identity 值。默认情况下,这是一个 GUID。添加名为 AddedCreatorToProperty 的新迁移:
[Powershell]
add-migration AddedCreatorToProperty
[CLI]
dotnet ef migrations add AddedCreatorToProperty
应用迁移,然后更新数据库中的许多属性,以便 CreatorId 列包含用户的 Id 值。我手动将三个用户的 Id 应用于数据库表中的前 20 条记录(图 10.8)。完成此作后,您就可以开始了。
图 10.8 更新属性,使 CreatorId 列填充现有用户的 Id 值。
10.4.1 创建需求和处理程序
资源授权或多或少与其他授权相同。它需要一个 requirement 和一个处理程序。对于要求,我将使用 ASP.NET Core 授权附带的 OperationAuthorizationRequirement 类。这是一个具有单个属性的帮助程序类:Name。该类旨在表示特定于对数据执行的最常见作的授权要求。为要授权的每个作创建此类的实例,然后相应地设置 Name 属性。清单 10.25 显示了许多实例,代表基本的 CRUD作,它们被分组到一个名为 PropertyOperations 的类中。此类位于 AuthorizationRequirements 文件夹中。
清单 10.25 实现 OperationAuthorizationRequirementss
using Microsoft.AspNetCore.Authorization.Infrastructure; ❶
namespace CityBreaks.AuthorizationRequirements
{
public static class PropertyOperations ❷
{
public static OperationAuthorizationRequirement Create =
new () { Name = nameof(Create) }; ❸
public static OperationAuthorizationRequirement Read =
new () { Name = nameof(Read) };
public static OperationAuthorizationRequirement Edit =
new () { Name = nameof(Edit) };
public static OperationAuthorizationRequirement Delete =
new () { Name = nameof(Delete) };
}
}
❶ OperationAuthorizationRequirement 帮助程序类位于 Microsoft.AspNetCore.Authorization.Infrastructure 命名空间中。
❷ PropertyOperations 是一个包装类,用于满足与 Property 类型作相关的多个授权要求。
❸ 实例化 OperationAuthorizationRequirement 的实例,并将其 Name 设置为 Create。其他 S 实例化时,其名称设置为其他 CRUD作。
现在,您已经拥有了需求类,可以创建处理程序。事实上,由于所有作的要求类型都是相同的(使用 OperationAuthorizationRequirement 类的一个主要好处),因此您只需创建一个处理程序 — 一个基于 AuthorizationHandler 的处理程序,其中 TResource 表示被授权的资源的类型。
处理程序类名为 PropertyAuthorizationhandler,并放置在 AuthorizationHandlers 文件夹中。为简洁起见,它仅包含对两个作的检查:Edit 和 Delete。由于将针对所有属性授权要求调用处理程序,因此请检查要求的 Name 属性以确定正在评估哪个特定要求。如果正在评估 Edit 要求,请检查当前用户的 NameIdentifier 声明中的其 ID,并将其与资源的 CreatorId 属性进行比较。如果存在匹配项,则当前用户创建了资源,因此您应该将要求标记为成功。
您还决定,只有具有 Admin 角色的用户才能删除属性。因此,如果当前要求是 Delete 要求,则仅在这些情况下将其标记为成功。您实际上不需要访问资源来进行此评估,但将资源的所有授权逻辑保存在一个位置是有意义的。处理程序的代码如下面的清单所示。
列表 10.26 PropertyAuthorizationHandler 类
using CityBreaks.AuthorizationRequirements;
using CityBreaks.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using System.Security.Claims;
namespace CityBreaks.AuthorizationHandlers
{
public class PropertyAuthorizationHandler :
AuthorizationHandler<OperationAuthorizationRequirement, Property> ❶
{
protected override Task ❷
➥ HandleRequirementAsync(AuthorizationHandlerContext context, ❷
OperationAuthorizationRequirement requirement, Property resource)❷
{
if(requirement.Name == PropertyOperations.Edit.Name) ❸❹
{ ❸
if (resource.CreatorId == ❸
➥ context.User.FindFirst(c => c.Type == ❸
➥ ClaimTypes.NameIdentifier).Value) ❸
{ ❸
context.Succeed(requirement); ❸
} ❸
} ❸
if(requirement.Name == PropertyOperations.Delete.Name) ❺
{ ❺
if (context.User.IsInRole("Admin")) ❺
{ ❺
context.Succeed(requirement); ❺
} ❺
} ❺
return Task.CompletedTask;
}
}
}
❶ TRequirement 是 OperationAuthorizationRequirement,TResource 是 Property。
❷ HandleRequirementAsync 方法同时采用需求和资源以及 Authorization-HandlerContext。
❸ 访问资源的 CreatorId 属性,并根据当前用户的 Id 检查其值。如果它们匹配,则要求成功。
❹ 检查当前要求的 Name 属性。如果它与 Edit requirement's name 相同,则处理授权检查。
❺ 如果要求是 Delete 要求,则检查当前用户是否为 Admin。
在这个阶段,您有一个选择。您可以像以前一样为您的需求注册正式策略,也可以选择直接根据需求进行评估。由于后者涉及的工作量较少,因此请选择该选项。
从数据库中检索要编辑的属性后,将在属性管理器 Edit 页面的 OnGetAsync 处理程序中进行 Edit authorization 检查。您将直接使用 IAuthorizationService 执行检查。它有一个 AuthorizeUserAsync 方法,该方法将用户、资源和要求作为参数,并返回一个具有布尔 Succeeded 属性的 AuthorizationResult 对象。您将传入当前用户、要编辑的属性以及 Edit 要求。如果 Succeeded 为 true,则当前用户已获得授权。否则,您将返回 Forbid 结果,该结果会将用户重定向到 Access Denied 终端节点。首先,您需要注入 IAuthorizationService 并在私有字段中捕获它。
清单 10.27 将 IAuthorizationService 注入 EditModel
public class EditModel : PageModel
{
private readonly IPropertyService _propertyService;
private readonly ICityService _cityService;
private readonly IAuthorizationService _authService;
public EditModel(IPropertyService propertyService,
ICityService cityService,
IAuthorizationService authService)
{
_propertyService = propertyService;
_cityService = cityService;
_authService = authService;
}
...
然后,修改 OnGetAsync 方法以执行授权检查。
清单 10.28 使用 AuthorizeUserAsync 评估用户、资源和需求
public async Task<IActionResult> OnGetAsync()
{
var property = await _propertyService.FindAsync(Id);
if (property == null)
{
return NotFound();
}
var result = await _authService.AuthorizeAsync(User,
➥ property, PropertyOperations.Edit);
if (!result.Succeeded)
{
return Forbid();
}
在我的应用程序版本中,Paul 是 Id 以 beed 开头的用户。从图 10.8 中可以看出,他是 Id 为 1 的属性的创建者。如果我以 Paul 身份登录并导航到 /property-manager /edit/1,则我可以访问“编辑”表单。如果我尝试将 URL 中的 1 替换为 2,则会显示 Access Denied 页面,因为 Paul 不是 ID 为 2 的属性的创建者。
如果您更喜欢根据策略进行检查,而不是直接检查需求,则可以像这样注册策略:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("EditPropertyPolicy", policyBuilder =>
policyBuilder.AddRequirements(PropertyOperations.Edit));
});
然后,将策略的名称传递给 AuthorizeUserAsync 方法,而不是要求的名称:
var result = await _authService.AuthorizeAsync(User, property,
➥ "EditPropertyPolicy");
除了需要更多的工作外,检查策略还需要使用字符串,您可以使用常量来解决这种情况,但这需要更多的工作。
还有一件事需要注意。目前,属性管理器索引页面列出了所有属性,并带有链接,可以查看其详细信息、编辑和删除它们。当用户没有授权时,向用户提供编辑或删除属性的链接似乎毫无意义。理想情况下,您仅在用户创建资产时提供编辑链接,如果他们具有 Admin 角色,则删除链接。在本章的最后一节中,您将使用 IAuthorizationService 根据当前用户和资源管理演示文稿。
10.4.2 将授权应用于 UI
当您希望根据资源在 UI 中评估授权时,请在 IAuthorizationService 上使用与刚才在属性管理器的 EditModel 中使用的相同的 AuthorizeAsync 方法。您需要使该服务可用于 Razor 页面,因此使用 inject 指令注入该服务。然后,根据方法调用的结果显示 Edit 和 Delete 链接。
以下列表详细介绍了对 PropertyManager \Index.cshtml 文件的代码更改。它需要在页面顶部使用几个 using 指令以及 inject 指令才能使 IAuthorizationService 可用。
清单 10.29 将 IAuthorizationService 注入到索引页中
@page
@model CityBreaks.Pages.PropertyManager.IndexModel
@using CityBreaks.AuthorizationRequirements
@using Microsoft.AspNetCore.Authorization
@inject IAuthorizationService authService
唯一其他必需的更改发生在包含链接的 td 元素中。将该元素替换为以下清单中的代码。
清单 10.30 使用 IAuthorizationService
<td>
<a asp-page="./Details" asp-route-id="@item.Id">Details</a>
@{
var result = await authService.AuthorizeAsync(User,
➥ item, PropertyOperations.Edit);
if (result.Succeeded)
{
@:|
<a asp-page="./Edit" asp-route-id="@item.Id">Edit</a>
}
result = await authService.AuthorizeAsync(User,
➥ item, PropertyOperations.Delete);
if (result.Succeeded)
{
@:|
<a asp-page="./Delete" asp-route-id="@item.Id">Delete</a>
}
}
</td>
在这里,您将使用与使用 EditModel 的 OnGetAsync 方法相同的方法和相同的参数。这一次,如果授权评估成功,则呈现一个链接。
当 Paul 登录时,他看到一些 Edit 链接,但根本看不到 Delete 链接,因为他没有 Admin 角色。当 Anna 登录时,她可以删除任何属性,但只有 Edit 链接可用于您为其分配为创建者的属性(图 10.9)。
图 10.9作链接不同,具体取决于登录者。
还有一件重要的事情需要牢记。虽然您已从页面中删除了 Delete 链接,但了解应用程序 URL 的人员仍可以向 Delete 端点提交请求。您需要应用授权检查,仅允许 Admin 角色中的用户能够执行此作。您决定是使用 Authorize 属性还是页面约定来应用授权检查,但需要添加检查。您可以像在第 5 章中介绍的那样考虑基于 UI 的授权:很高兴有,但您仍然必须在服务器上应用检查,并且终端节点授权等同于服务器端输入验证。
在本章中,您已经完成了对所有主要框架功能的回顾。最后四章涵盖了您需要了解的一系列主题,以便对应用程序进行故障排除、保护应用程序并发布应用程序。在此之前,我们将在下一章中介绍与 Razor Pages 框架相关的客户端开发的某些方面,特别是 AJAX 技术的使用。
总结
授权是确定允许用户执行哪些作的过程。
授权通过 AddAuthorization 添加为服务,并通过 UseAuthorization 作为中间件启用。这应该放在身份验证之后。如果用户未经过身份验证,则无法授权用户。
您可以将授权应用于终端节点 (页面) 和资源。
使用 Authorize 属性,可以将简单授权应用于终端节点。默认情况下,它只允许经过身份验证的用户。
或者,您可以使用页面约定向页面、文件夹和区域添加授权,从而允许您集中授权配置。
ASP.NET Identity 支持基于角色的授权,这使您能够将具有相同权限的用户分组在一起。
您可以使用当前用户的声明作为授权的基础,这些声明表示有关用户的数据并构成其身份的一部分。
授权建立在要求、处理程序和策略之上。每个策略都由一个或多个需求组成,每个需求都有一个或多个处理程序。
要求处理程序包含用于确定当前用户是否获得授权的逻辑。处理程序调用 context.Succeed(requirement) 将要求标记为成功。
只有一个处理程序需要成功,要求就会成功。必须满足所有要求才能授予授权。
您可以通过调用 context 来确保授权失败。Fail 方法。
授权包括许多内置策略,包括需要一个或多个角色、一个或多个声明、经过身份验证的用户和特定用户名的策略。
您可以创建自己的自定义要求、处理程序和策略。自定义处理程序必须注册为服务。
资源授权使您能够对应用程序模型元素的访问应用精细控制。
OperationAuthorizationRequirement 帮助程序类旨在帮助定义用于授权对资源执行特定作的处理程序。
可以将 IAuthorizationService 注入 Razor 页面,以将授权应用于 UI 的呈现。