使用 ASP.NET Core 构建 Web API:9 身份验证和授权
本章涵盖
- 了解身份验证和授权
- 获取 ASP.NET 核心标识的概述
- 通过用户帐户和 JSON Web 令牌实现身份验证
- 使用 AuthorizeAttribute 和 IAuthorizationFilter 启用授权
- 了解基于角色的访问控制 (RBAC) 授权策略
我们在前面几章中构建的 ASP.NET Core Web API 已经成型。但是,在发布它之前,我们必须解决一些我们有意保持打开状态的主要安全权限问题。如果我们仔细看看我们的BoardGamesController,DomainsController和MechanicsController,我们可以看到它们都有一些Post和Delete方法,任何人都可以用它来改变我们的宝贵数据。我们不希望这样,是吗?
出于这个原因,在考虑通过互联网部署我们的 Web API 并使其可公开访问之前,我们需要找到一种方法将这些方法的使用限制为有限的一组授权用户。在本章中,我们将学习如何使用 ASP.NET Core Identity 来执行此操作,核心标识是一个内置 API,可用于管理用户、角色、声明、令牌、策略、与授权相关的行为和其他功能。
9.1 基本概念
在深入研究代码之前,最好先概述一下身份验证和授权的概念。虽然这两个术语经常在同一上下文中使用,但它们具有不同、精确的含义。
9.1.1 身份验证
在信息安全中,身份验证是指验证计算机、软件或用户正确身份的行为。我们可以说,身份验证是一种验证实体(或个人)是它声称(或他们声称)的机制。
无论出于何种原因,身份验证过程对于需要唯一标识其用户的任何 Web 应用或服务都至关重要 – 限制对部分(或全部)用户数据的访问、收集个人信息、记录和/或跟踪用户在使用服务时的操作、注意他们是否已登录、在某个非活动期后断开它们, 等等。
此外,身份验证通常在增强 Web 服务(及其背后的组织)的数据保护、安全性和监视功能方面发挥重要作用。唯一地验证关联主体的身份意味着系统内执行的所有操作都可以合理确定地追溯到其作者,从而促进遵守组织的问责制政策。
问 责
问责制是 ISO/IEC 27001 的关键原则,ISO/IEC <> 是众所周知的国际标准,为组织内设计、实施和运营信息安全管理系统提供了系统的方法。
大多数欧盟隐私机构也强调了身份验证和问责制之间的联系。根据意大利数据保护局的说法,“. .共享凭据可防止在计算机系统中执行的操作归因于特定的负责人,也损害了所有者,剥夺了检查此类相关技术人物工作的可能性“(第4/4/2019条)。
大多数 Web 应用、Web 服务和 IT 设备都要求其用户在授予访问权限之前完成某种身份验证过程。此过程可能涉及使用指纹解锁我们的智能手机,登录Facebook或LinkedIn帐户,在Instagram上发布照片 – 所有形式的身份验证过程,即使其中一些是在后台执行的,因为用户同意他们的设备存储他们的凭据并自动使用它们。
现在有几种身份验证技术可用,例如用户名(或电子邮件)和密码;发送到电子邮件或移动设备的一次性 PIN 码 (OTP);个人认证应用生成的一次性安全码;以及指纹、视网膜和/或语音等生物特征扫描。我不会在本章中介绍所有这些技术,但一些在线资源可以提供有关这些主题的更多信息。
提示有关 ASP.NET Core 应用中身份验证的更多详细信息,请查看 http://mng.bz/jm6y。
9.1.2 授权
一般而言,授权是指行使、执行或行使某些权利的许可或权力。在IT领域,授权被定义为系统能够将访问权限(也称为权限)分配给单个计算机,软件或用户(或组)的过程。这些任务通常通过实现访问策略、声明或权限组来处理,这些策略、声明或权限组允许或禁止一组给定逻辑空间(文件系统文件夹、驱动器网络、数据库、网站部分、Web API 终结点等)中的每个相关操作或活动(读取、写入、删除等)。实际上,通常通过定义一系列访问控制列表 (ACL) 来提供或拒绝授权,这些列表指定
- 特定资源允许的访问类型(读取、写入、删除等)
- 授予或拒绝哪些计算机、软件或用户(或组)访问权限
尽管授权是正交的并且独立于身份验证,但这两个概念本质上是交织在一起的。如果系统无法识别其用户,则无法将其与其ACL正确匹配,从而授予或拒绝对其资源的访问权限。因此,大多数访问控制机制都设计为同时要求身份验证和授权。更准确地说,它们执行以下操作:
- 将尽可能低的授权权限分配给未经身份验证的(匿名)用户。这些权限通常包括访问公共(无限制)内容以及登录页面、模块或表单。
- 对成功执行登录尝试的用户进行身份验证。
- 检查其 ACL 以将适当的访问权限(权限)分配给经过身份验证的用户。
- 是否授权用户访问受限制的内容,具体取决于授予他们或他们所属的组的权限。
图 9.1 描述了此方案中描述的身份验证和授权流。该图模拟了具有一组只能由授权用户访问的资源的典型 Web 应用程序的行为。
图9.1 认证授权流程
在图中,身份验证过程应在授权之前进行,因为后者需要前者来执行其工作。但这种情况不一定是真的。如果匿名用户尝试访问受限资源,授权系统将在身份验证之前启动,拒绝对未经身份验证的用户的访问,并可能驱动 Web 应用程序将用户重定向到登录页面。在某些边缘情况下,甚至可能存在只能由匿名用户(未经身份验证的用户)访问的资源。一个典型的示例是登录页面,因为在注销之前,绝不应允许经过身份验证的用户执行其他登录尝试。
注意将所有这些点连接起来,我们应该看到身份验证和授权是不同的、独立的和独立的东西,即使它们最终是为了一起工作。即使授权可以在不知道连接方身份的情况下工作(只要它为未经身份验证的用户提供可行的 ACL),它也需要一个身份验证机制来完成其其余的工作。
现在我们已经有了大致的了解,我们需要了解如何在 Web API 中实现可行的身份验证和授权机制。正如我们之前所了解的,在典型的 Web 应用程序中,身份验证过程(通常由登录阶段表示)应该在授权部分之前发生。当用户成功登录后,我们将了解该用户的权限并授权他们去(或不去)任何地方。
但我们也(可能)知道HTTP协议是无状态的。每个请求都是独立执行的,不知道之前执行的请求。客户端和服务器在请求/响应周期内执行的所有操作(包括发送和/或接收的所有数据)都将在响应结束时丢失,除非客户端和服务器配备了一些机制来将此数据存储在某个位置。
注意这些机制不是 HTTP 协议的一部分,但它们通常利用其某些功能;换句话说,它们是建立在它之上的。很好的例子是我们在第8章中看到的缓存技术,它可以在客户端和/或服务器端实现。这些技术使用一组特定的 HTTP 标头(如缓存控制)来指示缓存服务要执行的操作。
如果我们将这两个事实联系起来,我们会看到我们遇到了一个问题:如果每个请求都不知道之前发生了什么,我们如何知道用户是否已通过身份验证?我们如何跟踪由登录表单触发的请求/响应周期的结果,即登录结果和(如果成功)用户的身份?下一节简要介绍一些解决此问题的方法。
实现方法
在现代 Web 服务和应用程序中设置 HTTP 身份验证的最常用方法是会话/cookie、持有者令牌、API 密钥、签名和证书。这些技术中的大多数不需要普通Web开发人员的介绍,但是花一些时间描述它们的工作原理可能是明智的:
- 会话/Cookie – 此方法依赖于键/值存储服务,通常位于 Web 服务器或外部服务器或集群上。Web 应用程序使用此服务来存储用户身份验证信息(会话),并为其分配自动生成的唯一 sessionId。然后,sessionId 通过 cookie 发送到浏览器,以便在所有后续请求中重新发送,并在服务器上用于检索用户的会话并以无缝、透明的方式采取相应的行动(执行基于授权的检查)。
- 持有者令牌 – 此方法依赖于身份验证服务器生成并包含相关授权信息的加密令牌。此令牌将发送到客户端,客户端可以通过在授权 HTTP 标头中设置令牌来使用它来执行后续请求(直到过期),而无需进一步的身份验证尝试。
- API 密钥 – 运行 Web API 的服务为其用户提供可用于访问 API 的 ClientID 和 CLIentSecret 对(或让他们有机会生成它们)。通常,该对在每个请求时通过授权 HTTP 标头发送。但是,与不需要身份验证的持有者令牌不同(稍后会详细介绍),ClientID 和 ClientSecret 通常用于每次对请求用户进行身份验证,以及授权该用户。
- 签名和证书 – 这两种身份验证方法使用以前共享的私钥和/或传输层安全性 (TLS) 证书执行请求的哈希。此技术可确保没有入侵者或中间人可以充当请求方,因为他们将无法“签署”HTTP 请求。这些方法对于安全性非常有用,但对于双方来说,它们可能很难设置和实施,这限制了它们对需要特别高的数据保护标准的服务。
我们应该为我们的MyBGList Web API使用以下哪种方法?与往常一样,我们应该考虑每种选择的利弊。以下是快速细分:
- 会话/cookie显然不在图片之外,因为它们会否定我们的RESTful目的,例如我们自第3章以来就知道的无状态约束。
- 持有者令牌提供了不错的安全态势,并且易于实现,特别是考虑到 ASP.NET 核心身份(几乎)开箱即用地支持它们。
- API 密钥提供了更好的安全态势,但它们需要大量额外的工作,例如提供专用的管理网站或 API 集,以使用户能够正确管理它们。
- 从安全角度来看,签名和证书很棒,但它们需要更多的额外工作,这可能会导致我们出现一些延迟和/或增加总体成本。
因为我们处理的是棋盘游戏,而不是敏感数据,所以至少从成本/收益的角度来看,持有者代币方法似乎是我们最好的选择。这种选择的好处是,它共享了实现 API 密钥方法所需的大部分工作。这是学习 ASP.NET 核心标识基本技术并通过为大多数 Web API 构建可行的身份验证和授权机制将其付诸实践的绝佳机会。下一节介绍持有者令牌的工作原理。
警告本章及其源代码示例的主要目的是概述可用于 Web API 的各种身份验证和授权机制,并就如何使用 ASP.NET 核心标识实现其中一些机制提供一般指导。但是,了解这些方法是黑客攻击、拒绝服务 (DoS) 攻击以及第三方执行的其他一些恶意活动的主要目标至关重要,这些活动可以轻松利用陷阱、实现错误、未更新的库、零日错误等。因此,如果你的 Web API 和/或其基础数据源包含个人、敏感或有价值的数据,请考虑通过使用我随它们提供的安全相关超链接以及有关每个主题的其他权威教程来集成或改进我们的代码示例来加强安全状况。
不记名令牌
基于令牌的身份验证(也称为持有者身份验证)是 Web API 最常用的方法之一。如果实施得当,它可以在不破坏无状态 REST 约束的情况下提供可接受的安全标准。
基于令牌的身份验证仍要求用户使用用户名和密码对自己进行身份验证(执行登录)。但是,身份验证过程成功后,服务器不会创建持久会话,而是生成一个加密的授权令牌,其中包含有关结果的一些相关信息,例如对用户标识 (userId) 的引用、有关连接客户端的一些信息、令牌到期日期等。此令牌一旦被客户端检索,就可以在任何后续请求的授权 HTTP 标头中设置,以获取对受限(授权)资源的访问权限,直到过期。图9.2总结了这一过程。
图9.2 持有者令牌授权流程
如我们所见,服务器不存储任何数据。至于客户端,实现可能会有所不同:令牌可以存储在本地(并重复使用直到过期)或在首次使用后丢弃。持有者令牌的主要优点是它们是一种独立的授权机制,因为它们的存在会自动意味着身份验证尝试成功。单个令牌可用于授权发往多个 Web API 和/或服务的受限请求,即使它们托管在其他地方和/或无法访问用户登录数据,只要它们共享生成它们的身份验证服务使用的相同颁发者签名密钥。
注意这种多功能性(和性能优势)也是主要安全漏洞的原因:代币发行后,它们不能轻易失效(或更新)。如果第三方设法窃取和使用令牌,他们将能够执行授权请求,直到令牌过期。此外,开发人员、系统管理员和用户无法轻松摆脱该令牌,即使他们知道它已被泄露。即使禁用原始用户也无法解决问题,因为该令牌是该用户仍处于活动状态时发生的身份验证过程的结果。此安全问题的最佳解决方法是尽可能缩短这些令牌的生命周期(理想情况下,缩短到几分钟),以便攻击者没有太多时间采取行动。
现在我们已经为具体方案选择了一条路径并了解了它应该如何工作,是时候熟悉我们将用于实现它的框架了。
9.2 ASP.NET 核心身份
ASP.NET 核心标识 API 提供了一组接口和高级抽象,可用于在任何 ASP.NET 核心应用中管理和存储用户帐户。尽管它可以与任何数据库和/或对象关系映射/映射器 (ORM) 一起使用,但该框架已经提供了多个类、帮助程序和扩展方法,允许我们将其所有功能与实体框架核心 (EF Core) 数据模型一起使用,这使其非常适合我们当前的方案。
注意ASP.NET 核心身份源代码是开源的,可在 GitHub 上找到 http://mng.bz/WAmx。
在以下部分中,我们将学习如何使用 ASP.NET 核心身份为我们现有的 MyBGList Web API 项目提供身份验证功能。(接下来将进行授权。为此,我们将执行以下步骤:
- 安装所需的 NuGet 包。
- 创建一个新的 MyBGListUser 实体类来处理用户名和密码等用户数据。
- 更新我们现有的 ApplicationDbContext,使其能够处理新的用户实体。
- 添加并应用新迁移,以使用核心标识所需的数据库表更新基础数据库 ASP.NET。
- 在程序.cs文件中设置和配置所需的标识服务和中间件。
- 实现新控制器来处理注册过程(创建新用户)和登录过程(将临时访问令牌分配给现有用户)。
9.2.1 安装 NuGet 包
若要将 ASP.NET 核心标识功能添加到项目中,我们需要以下 NuGet 包:
- Microsoft.Extensions.Identity.Core,包含成员系统以及处理我们需要的各种登录功能的主要类和服务
- Microsoft.ASPNetCore.Identity.EntityFrameworkCore,EF Core 的 ASP.NET Core Identity 提供程序
- Microsoft.AspNetCore.Authentication.JwtBearer,包含使 ASP.NET 核心应用程序能够处理JSON Web令牌(JWT)的中间件
与往常一样,我们可以选择使用 NuGet 包管理器或包管理器控制台在 Visual Studio 中安装所需的 NuGet 包,或者使用 .NET Core 命令行界面 (CLI) 从命令行安装所需的 NuGet 包。若要使用 CLI,请打开命令提示符,导航到项目的根文件夹,然后键入以下命令:
> dotnet add package Microsoft.Extensions.Identity.Core --version 6.0.11> dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore --➥ version 6.0.11> dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --➥ version 6.0.11
现在我们可以开始编写一些东西,从我们在第 4 章中创建的 ApplicationDbContext 类开始。
9.2.2 创建用户实体
现在我们已经安装了标识包,我们需要创建一个新的实体类,表示我们要进行身份验证和授权的用户。此实体的名称将为 ApiUser。
注意理想情况下,我们可以称这个实体为User,但该通用名称会与其他内置属性(如ControllerBase.User)产生一些令人讨厌的冲突。为了避免这个问题,我强烈建议选择一个更独特的名称。
因为我们使用的是 ASP.NET 核心身份,所以我们可以实现新实体的最好办法是扩展框架提供的默认实现来处理由 IdentityUser 类(Microsoft的一部分)表示的身份用户。AspNetCore.Identity 命名空间)。创建一个新的 /Model/ApiUser.cs 类文件,并使用以下代码填充该文件:
using Microsoft.AspNetCore.Identity; namespace MyBGList.Models{ public class ApiUser : IdentityUser { }}
就这样。我们现在不需要实现更多的东西,因为 IdentityUser 类已经包含我们需要的所有属性:用户名、密码等。
提示由于篇幅原因,我不会提供对 IdentityUser 默认类的广泛描述。若要了解有关它(及其属性)的详细信息,请参阅 http://mng.bz/8182 中的定义。
现在我们有一个专用的实体来处理我们的用户,我们可以更新我们的 ApplicationDbContext 类以充分利用它。
9.2.3 更新应用程序数据库上下文
在第 4 章中,当我们创建 ApplicationDbContext 类时,我们扩展了 DbContext 基类。为了使它能够处理我们新的 ApiUser 实体,我们需要使用另一个基类来更改它,该基类包含我们需要 ASP.NET 核心标识功能。这个基类的名称是(你可能猜到的)IdentityDbContext,它是我们之前安装的Microsoft.AspNetCore.Identity.EntityFrameworkCore NuGet包的一部分。以下是我们如何做到这一点(更新的代码以粗体显示):
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; ❶ // ... existing code public class ApplicationDbContext : IdentityDbContext<ApiUser> ❷
❶ 必需的命名空间
❷ 新的 IdentityDbContext<TUser> 基类
请注意,新的基类需要一个 TUser 类型的对象,该对象必须是 IdentityUser 类型的类。在此处指定我们的 ApiUser 实体指示由 ASP.NET Core 标识扩展包提供支持的 EF Core 在其上使用其标识功能。
9.2.4 添加和应用新迁移
现在,我们已经使应用程序数据库上下文知道了我们的新用户实体,我们准备添加新的迁移来更新基础 SQL Server 数据库,使用我们在第 4 章中学习的代码优先方法创建 ASP.NET 核心标识所需的数据库表。打开新的命令提示符,导航到 MyBGList 项目的根文件夹,然后键入以下内容以创建新的迁移:
> dotnet ef migrations add Identity
然后键入以下命令以将迁移应用到我们的 MyBGList 数据库:
> dotnet ef database update Identity
如果一切顺利,CLI 命令应显示文本,记录两个任务的成功结果。我们可以通过打开 SQL Server Management Studio (SSMS) 来仔细检查结果,以查看是否已创建新的 ASP.NET 核心标识表。预期结果如图9.3所示。
图9.3 ASP.NET 核心标识表
根据 ASP.NET 核心标识默认行为,所有标识数据库表都有一个 AspNet 前缀,这通常是一件好事,因为它允许我们轻松地将它们与其他表区分开来。
管理迁移(以及处理基于迁移的错误)
迁移功能是 EF Core 的独特优势之一,因为它允许开发人员以增量方式更新数据库架构,使其与应用程序的数据模型保持同步,同时保留数据库中的现有数据,以及随时回滚到以前的状态,就像我们对源代码管理所做的那样。但从长远来看,此功能可能很难维护,特别是如果我们意外删除了 dotnet-ef 工具生成的增量文件之一。发生这种情况时,任何使用 CLI 更新现有数据库架构的尝试都可能会返回 SQL 错误,例如“表/列/键已存在”。避免看到此错误消息的唯一方法是保留所有迁移文件。这就是为什么我们遵循在项目内部的文件夹中生成它们的良好做法,确保它们与其余代码一起置于源代码管理之下。
尽管有这些对策,但在某些边缘情况下,迁移的增量机制可能会不可挽回地中断;我们将无法恢复和/或回滚到安全状态。每当发生这种情况时,或者如果我们丢失了迁移文件而无法恢复它,我们能做的最好的事情就是重置所有迁移并创建一个与我们当前数据库架构同步的新迁移。这个过程涉及一些手工工作,称为挤压,并在 http://mng.bz/Eljl 的Microsoft官方指南中进行了详细解释。
如果我们想更改表名,我们可以通过重写 ApplicationDbContext 的 OnModelCreate 方法中的默认值来实现,如下所示(但不要在代码中执行此操作):
modelBuilder.Entity<ApiUser>().ToTable("ApiUsers");modelBuilder.Entity<IdentityRole<string>>().ToTable("ApiRoles");modelBuilder.Entity<IdentityRoleClaim<string>>().ToTable("ApiRoleClaims");modelBuilder.Entity<IdentityUserClaim<string>>().ToTable("ApiUserClaims");modelBuilder.Entity<IdentityUserLogin<string>>().ToTable("ApiUserLogins");modelBuilder.Entity<IdentityUserRole<string>>().ToTable("ApiRoles");modelBuilder.Entity<IdentityUserToken<string>>().ToTable("ApiUserTokens");
此代码会将 AspNet 前缀替换为 Api。但我们不会在代码示例中执行此操作;我们将保留默认前缀。
9.2.5 设置服务和中间件
现在我们需要在我们的程序.cs文件中设置和配置一些服务和中间件。我们需要添加以下内容:
- 身份服务 – 执行注册和登录过程
- 授权服务 – 定义颁发和读取 JWT 的规则
- 身份验证中间件 – 将 JWT 读取任务添加到 HTTP 管道
让我们从标识服务开始。
添加身份服务
以下是我们需要做的:
- 将 ASP.NET 核心标识服务添加到服务容器。
- 配置用户密码的最低安全要求(也称为密码强度)。
- 添加 ASP.NET 身份验证中间件。
打开 Program.cs 文件,找到我们将 DbContext 添加到服务容器的部分,并在它下面添加清单 9.1 中的代码(粗体新行)。
清单 9.1 程序.cs文件:标识服务
using Microsoft.AspNetCore.Identity; ❶ builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer( builder.Configuration.GetConnectionString("DefaultConnection")) ); builder.Services.AddIdentity<ApiUser, IdentityRole>(options => ❷{ options.Password.RequireDigit = true; ❸ options.Password.RequireLowercase = true; ❸ options.Password.RequireUppercase = true; ❸ options.Password.RequireNonAlphanumeric = true; ❸ options.Password.RequiredLength = 12; ❸}) .AddEntityFrameworkStores<ApplicationDbContext>();
❶ 必需的命名空间
❷ 添加身份服务
❸ 配置密码强度要求
如我们所见,我们告诉 ASP.NET 标识仅接受具有以下特征的密码
- 至少一个小写字母
- 至少一个大写字母
- 至少一个数字字符
- 至少一个非字母数字字符
- 至少 12 个字符
这些安全标准将为我们的用户提供非数据敏感方案的良好级别的身份验证安全性。下一步是设置身份验证服务。
添加身份验证服务
在我们的方案中,身份验证服务具有以下用途:
- 将 JWT 定义为默认身份验证方法
- 启用 JWT 持有者身份验证方法
- 设置 JWT 验证、颁发和生存期设置
下面的清单包含相关代码,我们可以将其放在标识服务正下方的程序.cs文件中。
清单 9.2 程序.cs文件:认证服务
using Microsoft.AspNetCore.Authentication.JwtBearer; ❶using Microsoft.IdentityModel.Tokens; ❶ builder.Services.AddAuthentication(options => { ❷ options.DefaultAuthenticateScheme = options.DefaultChallengeScheme = options.DefaultForbidScheme = options.DefaultScheme = options.DefaultSignInScheme = options.DefaultSignOutScheme = JwtBearerDefaults.AuthenticationScheme; ❸}).AddJwtBearer(options => { ❹ options.TokenValidationParameters = new TokenValidationParameters ❺ { ValidateIssuer = true, ValidIssuer = builder.Configuration["JWT:Issuer"], ValidateAudience = true, ValidAudience = builder.Configuration["JWT:Audience"], ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey( System.Text.Encoding.UTF8.GetBytes( builder.Configuration["JWT:SigningKey"]) ) };});
❶ 必需的命名空间
❷ 添加身份验证服务
❸ 设置默认授权相关方案
❹ 添加 JWT 持有者身份验证方案
❺ 配置 JWT 选项和设置
JWT 持有者选项部分是代码中最有趣的部分,因为它决定了身份验证服务应如何验证令牌。如我们所见,我们要求验证颁发者、受众和颁发者用于对令牌进行签名的密钥 (IssuerSigningKey)。执行这些检查将大大减少恶意第三方颁发或伪造有效令牌的机会。
请注意,我们没有直接在代码中指定这些参数,而是使用了对配置文件的引用。我们现在需要更新这些文件,以便源代码能够检索这些值。
更新 appsettings.json 文件
打开 appsettings.json 文件,并在现有 SeriLog 项的正下方添加以下顶级部分:
"JWT": { "Issuer": "MyBGList", "Audience": "MyBGList", "SigningKey": "MyVeryOwnTestSigningKey123$" }
与往常一样,如果计划在可公开访问的生产环境中部署 Web API,请务必使用自己的值更改示例值。
提示在 secret.json 文件中移动签名密钥将确保更好的安全态势。请务必执行此操作,除非你正在处理像这样的示例应用。
现在我们的服务已经正确设置,我们几乎完成了程序.cs文件。现在缺少的只是身份验证中间件。
添加身份验证中间件
在 Program.cs 文件中,向下滚动到现有行
app.UseAuthorization();
并在其前面添加 ASP.NET Core 身份验证中间件:
app.UseAuthentication(); ❶app.UseAuthorization(); ❷
❶ 新的身份验证中间件
❷ 现有授权中间件
从第2章开始,我们就知道中间件顺序很重要,因为中间件按顺序影响HTTP请求管道。因此,请确保在 UseAuthorization() 之前调用 UseAuthentication(),因为我们的应用需要知道使用哪种身份验证方案和处理程序来授权请求。现在,我们已经设置并配置了 ASP.NET Core Identity 服务和身份验证中间件,我们已准备好实现用户将用于创建其帐户(注册)然后对自己进行身份验证(登录)的操作方法。
9.2.6 实现帐户控制器
在本节中,我们将创建一个新的AccountController,并使用两种操作方法填充它:注册(创建新用户)和登录(对其进行身份验证)。这两种方法都需要一些必需的输入参数才能执行其工作。例如,Register 方法需要想要创建帐户的用户的数据(用户名、密码、电子邮件等),而 Login 方法只需要知道用户名和密码。由于帐户控制器必须处理一些与核心标识相关的特定 ASP.NET 任务,因此我们将需要以下以前从未使用过的服务:
- 用户管理器 – 提供用于管理用户的 API
- 登录管理器 – 提供用于登录用户的 API
这两个服务都是 Microsoft.AspNetCore.Identity 命名空间的一部分。我们将需要第一个用于注册方法,第二个用于处理登录。此外,因为我们还需要读取我们在appsettings.json配置文件中指定的JWT设置,所以我们也需要IConfiguration接口。与往常一样,所有这些依赖项都将通过依赖项注入提供。
让我们从控制器本身中的空样板开始。在项目的 /Controllers/ 文件夹中创建一个新的 AccountController.cs C# 类文件,并使用以下清单中的代码填充该文件。
清单 9.3 帐户控制器样板
using Microsoft.AspNetCore.Mvc;using Microsoft.EntityFrameworkCore;using MyBGList.DTO;using MyBGList.Models;using System.Linq.Expressions;using System.Linq.Dynamic.Core;using System.ComponentModel.DataAnnotations;using MyBGList.Attributes;using System.Diagnostics;using Microsoft.AspNetCore.Identity; ❶using Microsoft.IdentityModel.Tokens; ❶using System.IdentityModel.Tokens.Jwt; ❶using System.Security.Claims; ❶ namespace MyBGList.Controllers{ [Route("[controller]/[action]")] ❷ [ApiController] public class AccountController : ControllerBase { private readonly ApplicationDbContext _context; private readonly ILogger<DomainsController> _logger; private readonly IConfiguration _configuration; private readonly UserManager<ApiUser> _userManager; ❸ private readonly SignInManager<ApiUser> _signInManager; ❹ public AccountController( ApplicationDbContext context, ILogger<DomainsController> logger, IConfiguration configuration, UserManager<ApiUser> userManager, ❸ SignInManager<ApiUser> signInManager) ❹ { _context = context; _logger = logger; _configuration = configuration; _userManager = userManager; ❸ _signInManager = signInManager; ❹ } [HttpPost] [ResponseCache(CacheProfileName = "NoCache")] public async Task<ActionResult> Register() ❺ { throw new NotImplementedException(); } [HttpPost] [ResponseCache(CacheProfileName = "NoCache")] public async Task<ActionResult> Login() ❻ { throw new NotImplementedException(); } }}
❶ ASP.NET 核心身份命名空间
❷ 路由属性
❸ 用户管理器接口
❹ 登录管理器接口
❺ 注册方式
❻ 登录方式
请注意,我们已经使用基于操作的路由规则(“[控制器]/[操作]”)定义了一个 [Route] 属性,因为我们必须处理需要区分的两个 HTTP POST 方法。由于该规则,我们的方法将具有以下端点:
/Account/Register/Account/Login
除此之外,我们还为 _userManager、_signInManager 和 _configuration 对象(通过依赖注入)设置了一个本地实例,并创建了两个未实现的方法。在以下部分中,我们将从 Register 开始实现这两种方法(及其 DTO)。
实现寄存器方法
如果我们查看 ASP.NET SQL Server数据库中为我们创建的核心标识的[AspNetUsers]表,我们会看到创建新用户所需的参数(图9.4)。
图 9.4 AspNetUsers 数据库表
此表用于存储我们之前创建的 ApiUser 实体的记录,该实体是 IdentityUser 默认类的扩展。如果我们检查该实体,我们会看到它对每个表列都有一个公共属性,这并不奇怪,因为我们首先使用了 EF Core 代码优先方法来创建表。
现在我们知道了我们需要从想要创建新帐户的用户那里获取的数据,我们可以实现 DTO 对象来“传输”他们,从而将第 6 章中的课程付诸实践。在项目的 /DTO/ 文件夹中创建一个新的 RegisterDTO.cs C# 类文件,并用清单 9.4 中所示的代码填充该文件。为简单起见,我们将要求注册用户向我们发送三种类型的信息:有效的用户名、他们想要用于执行登录的密码以及他们的电子邮件地址。
清单 9.4 注册DTO类
using System.ComponentModel.DataAnnotations; namespace MyBGList.DTO{ public class RegisterDTO { [Required] public string? UserName { get; set; } [Required] [EmailAddress] public string? Email { get; set; } [Required] public string? Password { get; set; } }}
现在我们有了DTO,我们可以使用它来实现我们的 帐户控制器 。注册方法,预期处理以下任务:
- 接受寄存器DTO输入。
- 检查模型状态以确保输入有效。
- 如果 ModelState 有效,则创建一个新用户(记录结果),并返回状态代码 201 – 已创建;否则,返回状态代码 400 – 记录错误的错误请求。
- 如果用户创建失败,或者整个过程中出现异常,则返回状态代码 500 – 内部服务器错误,并返回相关错误消息。
下面的清单显示了我们如何实现这些任务。
清单 9.5 帐户控制器.注册方法
[HttpPost][ResponseCache(CacheProfileName = "NoCache")]public async Task<ActionResult> Register(RegisterDTO input){ try { if (ModelState.IsValid) ❶ { var newUser = new ApiUser(); newUser.UserName = input.UserName; newUser.Email = input.Email; var result = await _userManager.CreateAsync( newUser, input.Password); ❷ if (result.Succeeded) ❸ { _logger.LogInformation( "User {userName} ({email}) has been created.", newUser.UserName, newUser.Email); return StatusCode(201, $"User '{newUser.UserName}' has been created."); } else throw new Exception( string.Format("Error: {0}", string.Join(" ", result.Errors.Select(e => e.Description)))); } else { var details = new ValidationProblemDetails(ModelState); details.Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"; details.Status = StatusCodes.Status400BadRequest; return new BadRequestObjectResult(details); } } catch (Exception e) ❹ { var exceptionDetails = new ProblemDetails(); exceptionDetails.Detail = e.Message; exceptionDetails.Status = StatusCodes.Status500InternalServerError; exceptionDetails.Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1"; return StatusCode( StatusCodes.Status500InternalServerError, exceptionDetails); }}
❶ 检查模型状态并采取相应措施
❷ 尝试创建用户
❸ 检查结果并采取相应措施
❹ 捕获任何异常并返回错误
这段代码应该不难理解。唯一的新东西是使用UserManager服务及其CreateAsync方法,该方法返回IdentityResult类型的对象,其中包含发生的结果或错误。现在我们有一个 Register 方法,我们可以通过尝试创建新用户来测试它。
创建测试用户
在调试模式下启动项目,并像往常一样等待 SwaggerUI 起始页加载。然后,我们应该看到一个新的 POST 帐户/注册端点,我们可以扩展它,如图 9.5 所示。
图 9.5 SwaggerUI 中的 /帐户/注册终结点
我们可以通过单击右上角的“试用”按钮来测试新方法。一旦我们这样做,我们将能够使用实际的用户名、电子邮件和密码值填充示例 JSON。让我们使用以下值进行第一次测试:
{ "userName": "TestUser", "email": "TestEmail", "password": "TestPassword"}
请求应返回 HTTP 状态代码 400,并带有解释错误原因(电子邮件格式无效)的响应正文:
{ "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "One or more validation errors occurred.", "status": 400, "errors": { "Email": [ "The Email field is not a valid e-mail address." ] }}
此响应表示模型状态验证工作正常。目前为止,一切都好。现在,让我们修复电子邮件字段并使用以下值执行新测试:
{ "userName": "TestUser", "email": "test-user@email.com", "password": "TestPassword"}
现在,请求应返回 HTTP 状态代码 500,并带有一个响应正文,解释新的错误原因(密码格式无效):
{ "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1", "status": 500, "detail": "Error: Passwords must have at least one non alphanumeric➥ character. Passwords must have at least one digit ('0'-'9')."}
该错误警告我们密码不够强 – 再次确认我们的验证检查正在工作。现在我们可以修复最后一个问题,并使用以下值执行第三个(理想情况下是最后一个)测试:
{ "userName": "TestUser", "email": "test-user@email.com", "password": "MyVeryOwnTestPassword123$"}
我们应该会收到一条确认消息,指出用户已创建。
注意随意将示例中的用户名和/或密码替换为您自己的值。但请务必记下它们,尤其是密码,因为 UserManager.CreateAsync 方法会将其作为不可逆的哈希值存储在 [AspNetUsers] 中。密码哈希]列。
现在我们完成了寄存器部分。让我们继续讨论登录方法。
实现登录方法
我们的任务是创建一个合适的登录DTO并使用它来实现登录操作方法。让我们从 LoginDTO 类开始,它(我们现在应该知道)只需要两个属性:用户名和密码(请参阅下面的列表)。
清单 9.6 登录DTO类
using System.ComponentModel.DataAnnotations; namespace MyBGList.DTO{ public class LoginDTO { [Required] [MaxLength(255)] public string? UserName { get; set; } [Required] public string? Password { get; set; } }}
现在我们可以实现 AccountController.Login 方法,该方法需要处理以下任务:
- 接受登录DTO输入。
- 检查模型状态以确保输入有效;否则,返回记录错误的状态代码 400 – 错误请求。
- 如果用户存在且密码匹配,请生成一个新令牌,并将其与状态代码 200 – 确定一起发送给用户。
- 如果用户不存在、密码不匹配和/或在此过程中发生任何异常,请返回状态代码 401 – 未经授权,并返回相关错误消息。
下面的清单包含这些任务的源代码。
9.7 账户控制器的登录方法
[HttpPost][ResponseCache(CacheProfileName = "NoCache")]public async Task<ActionResult> Login(LoginDTO input){ try { if (ModelState.IsValid) ❶ { var user = await _userManager.FindByNameAsync(input.UserName); if (user == null || !await _userManager.CheckPasswordAsync( user, input.Password)) throw new Exception("Invalid login attempt."); else { var signingCredentials = new SigningCredentials( ❷ new SymmetricSecurityKey( System.Text.Encoding.UTF8.GetBytes( _configuration["JWT:SigningKey"])), SecurityAlgorithms.HmacSha256); var claims = new List<Claim>(); ❸ claims.Add(new Claim( ClaimTypes.Name, user.UserName)); var jwtObject = new JwtSecurityToken( ❹ issuer: _configuration["JWT:Issuer"], audience: _configuration["JWT:Audience"], claims: claims, expires: DateTime.Now.AddSeconds(300), signingCredentials: signingCredentials); var jwtString = new JwtSecurityTokenHandler() ❺ .WriteToken(jwtObject); return StatusCode( ❻ StatusCodes.Status200OK, jwtString); } } else { var details = new ValidationProblemDetails(ModelState); details.Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"; details.Status = StatusCodes.Status400BadRequest; return new BadRequestObjectResult(details); } } catch (Exception e) ❼ { var exceptionDetails = new ProblemDetails(); exceptionDetails.Detail = e.Message; exceptionDetails.Status = StatusCodes.Status401Unauthorized; exceptionDetails.Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1"; return StatusCode( StatusCodes.Status401Unauthorized, exceptionDetails); }}
❶ 检查模型状态并采取相应措施
❷ 生成签名凭据
❸ 设置用户声明
❹ 实例化 JWT 对象实例
❺ 生成 JWT 加密字符串
❻ 将 JWT 返回给调用方
❼ 捕获任何异常并返回错误
同样,此代码应该易于理解 – 除了 JWT 生成部分,它值得一些额外的解释。该部分可以分为四个部分,我用空格分隔,每个部分设置一个变量,该变量在 JWT 创建过程中起着独特的作用:
- 签名凭据 – 此变量存储使用 HMAC SHA-256 加密算法加密的 JWT 签名。请注意,签名密钥是从配置设置中检索的,与前面的程序.cs文件中的授权服务一样。此方法可确保写入和读取过程将使用相同的值,这意味着签名密钥将匹配。
- 声明 – 此变量存储我们要为其生成 JWT 的用户的声明列表。授权过程将使用这些声明来检查是否允许用户访问每个请求的资源(稍后会详细介绍)。请注意,现在,我们正在设置一个与用户的 UserName 属性对应的声明。我们很快就会添加更多声明。
- jwtObject – 此变量通过将签名凭据、声明列表、配置文件检索的颁发者和受众值以及合适的过期时间(300 秒)放在一起来存储 JWT 本身的实例(作为 C# 对象)。
- jwtString – 此变量存储 JWT 的加密字符串表示形式。此值是我们需要发送回客户端的值,以便他们可以在后续请求的授权标头中设置它。
注意我们正在使用其他几个UserManager方法:FindByNameAsync和CheckPasswordAsync。因为他们的名字是不言自明的,所以理解他们做什么应该不难。
使用此方法,我们的帐户控制器已准备就绪,我们实现的身份验证部分也已准备就绪。现在我们需要测试它。
对测试用户进行身份验证
要测试帐户控制器的登录方法,我们可以使用通过注册方法创建的测试用户。在调试模式下启动项目,访问 SwaggerUI 主仪表板,然后选择新的 POST 帐户/登录端点(图 9.6)。
图 9.6 SwaggerUI 中的 /帐户/登录端点
单击右上角的试用,并使用我们创建的测试用户的用户名和密码值填充示例 JSON:
{ "userName": "TestUser", "password": " MyVeryOwnTestPassword123$"}
如果我们正确执行了所有操作,我们应该收到状态代码 200 – OK 响应,响应正文中带有 JWT(图 9.7)。
图 9.7 /帐户/使用 JWT 的登录响应
现在,我们的 Web API 配备了有效的身份验证机制,包括通过 ASP.NET 核心身份处理的注册和登录过程。在下一节中,我们将基于它定义一些授权规则。
9.3 授权设置
在本部分中,我们将使用由帐户控制器的登录方法生成的 JWT 将我们的某些 API 终结点限制为授权用户。为了获得这个结果,我们需要注意两个不同的方面:
- 客户端 – 添加包含 JWT 的授权 HTTP 标头,以使用我们选择的测试客户端 (SwaggerUI) 正确模拟某些“授权”请求。
- 服务器端 – 设置一些授权规则,使某些现有控制器(和最小 API)的操作方法仅供具有具有所需声明的有效 JWT 的调用方访问。
9.3.1 添加授权 HTTP 标头
由于我们的帐户控制器的登录方法以纯文本形式返回 JWT,因此我们可以做的最有效的事情是更新我们现有的 Swashbuckler SwaggerUI 配置,使其接受任意字符串(如果存在),该字符串将在执行请求之前放入授权 HTTP 标头中。与往常一样,所需的更新将在程序.cs文件中执行。
从客户端处理授权标头
我们将要实现的技术旨在模拟实际的 REST 客户端在执行请求时会执行的操作。JWT 不应手动处理。大多数客户端 JavaScript 框架(如 Angular 和 React)提供(或允许使用)HTTP 拦截器,这些拦截器可用于在调度之前将任意标头(例如带有先前获取的令牌的授权标头)附加到所有请求。
有关 HTTP 拦截器的其他信息,请查看以下 URL:
- 角度(内置接口):https://angular.io/api/common/http/HttpInterceptor
- Axios(用于 React 和其他框架):https://axios-http.com/docs/interceptors
我们需要添加新的安全定义,以告诉 Swagger 我们希望 API 的保护类型,以及全局强制实施的新安全要求。以下清单显示了如何操作。
示例 9.8 程序.cs文件:Swagger的持有者令牌设置
using Microsoft.OpenApi.Models; ❶ // ... existing code builder.Services.AddSwaggerGen(options => { options.ParameterFilter<SortColumnFilter>(); options.ParameterFilter<SortOrderFilter>(); options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme ❷ { In = ParameterLocation.Header, Description = "Please enter token", Name = "Authorization", Type = SecuritySchemeType.Http, BearerFormat = "JWT", Scheme = "bearer" }); options.AddSecurityRequirement(new OpenApiSecurityRequirement ❸ { { new OpenApiSecurityScheme { Reference = new OpenApiReference { Type=ReferenceType.SecurityScheme, Id="Bearer" } }, Array.Empty<string>() } });});
❶ 必需的命名空间
❷ 新的招摇安全定义
❸ 新的招摇安全要求
由于此更新,带有挂锁图标的新授权按钮将出现在 SwaggerUI 的右上角(图 9.8)。如果我们单击它,将出现一个弹出窗口,让我们有机会插入要在授权 HTTP 标头中使用的持有者令牌。
图9.8 SwaggerUI授权按钮和弹窗
这正是我们需要将 JWT 添加到我们的请求中的内容。现在,作业的客户端部分已经完成,我们可以切换到服务器端。
9.3.2 设置 [授权] 属性
我们必须选择哪些 API 端点应该对所有人可用(因为它们已经可用),以及限制、限制或阻止哪些端点。典型的方法是允许对只读终结点进行公共/匿名访问,这些终结点不泄露保留数据,并将其他所有内容限制为经过身份验证(和授权)的用户。让我们使用通用逻辑,根据产品所有者的显式请求设计给定的实现方案。假设我们希望保持对所有操作方法的无限制访问,但以下方法除外:
- 棋盘游戏控制器 – 发布、删除
- 域控制器 – 发布、删除
- 机械控制器 – 发布、删除
- 种子控制器 – 放置
我们可以很容易地看到,所有这些方法都是为了将永久更改应用于我们的数据库,因此将它们放在授权规则后面很有意义。我们绝对不希望某些匿名用户删除、更新或以其他方式更改我们的棋盘游戏数据!
为了将我们的计划付诸实践,我们可以用 [Authorize] 属性来修饰这些方法,该属性是 Microsoft.AspNetCore.Authorization 命名空间的一部分。此属性可应用于控制器、操作方法和最小 API 方法,以根据身份验证方案、策略和/或角色设置特定的授权规则。可以使用属性的参数配置这些规则(我们将在稍后看到)。在没有参数的情况下使用时,[Authorize] 属性以其最基本的形式将限制对经过身份验证的用户的访问,无论其权限如何。
由于我们尚未为用户定义任何策略或角色,因此可以使用属性的无参数行为开始实现过程。打开以下控制器:BoardGamesController、DomainsController、MechanicsController和SeedController。然后将 [Authorize] 属性添加到其发布、删除和放置方法中,如下所示:
using Microsoft.AspNetCore.Authorization; ❶ // ... existing code [Authorize] ❷ [HttpPost(Name = "UpdateBoardGame")] [ResponseCache(CacheProfileName = "NoCache")] public async Task<RestDTO<BoardGame?>> Post(BoardGameDTO model)
❶ 必需的命名空间
❷ 授权属性
现在,我们所有可能更改数据的操作方法将只有经过身份验证的用户才能访问。
选择默认访问行为
请务必了解,通过将 [Authorize] 属性应用于某些特定操作方法,我们将为未经授权的用户隐式设置默认允许、选择阻止逻辑。换句话说,我们的意思是,除了那些受 [Authorize] 属性限制的操作方法之外,所有操作方法都允许匿名访问。我们可以将此逻辑反转为默认阻止,选择允许,方法是将 [Authorize] 属性设置为整个控制器,然后有选择地将 [AllowAnonymous] 属性用于我们希望每个人都可以访问的操作方法。
这两种行为都是可行的,具体取决于特定的用例。一般而言,限制性更强的方法(默认阻止,选择允许)被认为更安全,不易发生人为(开发人员)错误。通常,忘记 [Authorize] 属性比忘记 [AllowAnonymous] 属性更糟糕,因为它很容易导致数据泄露。
在我们的示例方案中,保护单个操作方法可能是可以接受的,至少对于那些具有混合访问行为(匿名和受限操作方法)的控制器。例外情况是种子控制器,它旨在仅托管受限制的操作方法。在这种情况下,在控制器级别设置 [Authorize] 属性会更合适。让我们在继续之前这样做。打开 SeedController.cs 文件,并将 [Authorize] 属性从操作方法移动到控制器:
[Authorize][Route("[controller]")][ApiController]public class SeedController : ControllerBase
由于此更新,我们将添加到此控制器的所有操作方法将自动限制为授权用户。我们不必记住做任何其他事情。
启用最小 API 授权
在进入测试阶段之前,我们应该看看如何在最小 API 中使用 [Authorize] 属性,而无需额外的努力。让我们添加一个最小 API 方法来处理新的 /auth/test/1 终结点,该终结点仅供授权用户访问。下面的清单包含源代码。
示例 9.9 程序.cs文件:/auth/test/1 最小 API 端点
using Microsoft.AspNetCore.Authorization; // ... existing code app.MapGet("/auth/test/1", [Authorize] [EnableCors("AnyOrigin")] [ResponseCache(NoStore = true)] () => { return Results.Ok("You are authorized!"); });
现在,我们终于准备好测试到目前为止所做的工作了。
9.3.3 测试授权流程
在调试模式下启动项目,然后访问 SwaggerUI 仪表板。与往常一样,此客户端是我们将用来执行测试的客户端。
我们应该做的第一件事是检查授权限制是否正常工作。我们添加的 /auth/test/1 最小 API 端点是该任务的完美候选项,因为它可以通过不会影响我们数据的简单 GET 请求来调用。我们将使用 SwaggerUI 调用该终结点,并确保它返回状态代码 401 – 未经授权的响应,如图 9.9 所示。
图 9.9 /auth/test/1 端点返回状态代码 401 – 未授权
目前为止,一切都好。对于具有 [Authorize] 属性的任何方法,预计会出现未经授权的响应,因为我们尚未通过身份验证。让我们填补这个空白,再试一次。执行以下步骤:
- 使用测试用户的用户名和密码值调用帐户/登录终结点,就像我们之前测试它时所做的那样,以接收有效的 JWT。
- 通过选择它并按 Ctrl C 将 JWT 复制到剪贴板。
- 单击我们添加的授权按钮以显示弹出窗口。
- 将 JWT 粘贴到弹出窗口的输入文本框中,然后单击授权以针对下一个请求进行设置。
现在我们可以调用 /auth/test/1 端点,看看持有者令牌是否允许我们执行此请求。如果一切顺利(并且我们在令牌过期之前的 300 秒内执行测试),我们应该会在响应正文中看到 200 – OK 状态代码和“您已 获得授权!”消息,如图 9.10 所示。
图 9.10 /auth/test/1 端点返回状态代码 200 – 正常
此结果表明我们基于 JWT 的身份验证和授权流正在工作;我们已成功将某些方法限制为授权用户。但是,我们的授权规则仍然是基本的。我们只能区分匿名用户和经过身份验证的用户,考虑到后者无需进一步检查即可获得授权。理想情况下,我们应该为我们的 Web API 提供一个更精细的访问控制系统,允许我们设置其他授权行为,例如仅授权某些用户执行某些操作。在下一部分中,我们将通过实现基于角色的声明机制来实现这一点。
9.4 基于角色的访问控制
假设我们要创建不同的 ACL 来支持以下经过身份验证的用户类型:
- 基本用户 – 应允许他们访问只读终端节点,而不能访问其他任何内容,例如匿名(未注册)用户。
- 审阅人 – 他们应有权访问只读端点和更新端点,但不能删除任何内容或为数据库设定种子。
- 管理员 – 他们应该能够执行任何操作(读取、更新、删除和播种)。
现在的情况是,所有经过身份验证的用户都被视为管理员:他们可以执行任何操作,因为 [Authorize] 属性仅检查该基本状态。要改变这种行为,我们需要找到一种方法将这些用户组织到不同的组中,并为每个组分配特定的权限。在 ASP.NET Core中,我们可以通过使用角色来实现此结果。
基于角色的访问控制 (RBAC) 是一种内置的授权策略,它提供了一种为不同用户分配不同权限的便捷方法。每个角色的行为都像一个组,因此我们可以向其添加用户并为其设置特定的授权规则。定义规则后,它们将应用于具有该特定角色的所有用户。
实施 RBAC 策略是处理任务的好方法,因为它允许我们对用户进行分类。简而言之,这是我们需要做的:
- 注册其他用户。我们至少需要其中的两个:TestModerator 和 TestAdministrator,每个都代表我们想要支持的用户类型。
- 创建一组预定义的角色。根据我们的要求,我们需要其中两个:版主和管理员。我们不需要为基本用户添加角色,因为他们应具有与匿名用户相同的权限,并且无参数 [Authorize] 属性已经处理了这些权限。
- 将用户添加到角色。具体来说,我们需要将测试管理员用户分配给审阅人角色,将测试管理员用户分配给管理员角色。
- 将基于角色的声明添加到 JWT。由于 JWT 包含经过身份验证的用户声明的集合,因此我们需要将用户的角色放在这些声明中,以便 ASP.NET Core 授权中间件能够确认这些声明并采取相应的操作。
- 设置基于角色的授权规则。我们可以通过更新要限制为版主和管理员的操作方法中的 [Authorize] 属性来执行此任务,以便他们要求 JWT 中存在相应的与角色相关的声明。
9.4.1 注册新用户
要做的第一件事应该很容易完成,因为我们在通过创建 TestUser 帐户测试帐户/注册终结点时就这样做了。我们必须执行相同的端点两次才能再添加两个用户。以下是我们可用于创建测试审查器帐户的 JSON 值:
{ "userName": "TestModerator", "email": "test-moderator@email.com", "password": "MyVeryOwnTestPassword123$"}
以下是测试管理员帐户的值:
{ "userName": "TestAdministrator", "email": "test-administrator@email.com", "password": "MyVeryOwnTestPassword123$"}
注意与往常一样,请随时更改用户名和/或密码。
用户就是这样。让我们继续处理角色。
9.4.2 创建新角色
创建角色的最方便方法是使用 RoleManager API,它是 Microsoft.AspNetCore.Identity 命名空间的一部分。我们将使用其 CreateAsync 方法,该方法接受 IdentityRole 对象作为参数,并使用它来在持久性存储(在我们的方案中为 [AspNetRoles] 数据库表)中创建新记录,为其分配唯一 ID。以下是我们如何实现它:
await _roleManager.CreateAsync(new IdentityRole("RoleName"));
如我们所见,IdentityRole 对象的构造函数接受表示角色名称的字符串类型的值。创建角色后,我们需要在代码中使用此名称来引用它。因此,将这些名称定义为常量可能是一个好主意。
添加角色名称常量
若要将这些名称定义为常量,请在 /Constants/ 文件夹中创建一个新的 RoleNames.cs 文件,并使用以下清单中的代码填充该文件。
清单 9.10 /常量/角色名称.cs文件
namespace MyBGList.Constants{ public static class RoleNames { public const string Moderator = "Moderator"; public const string Administrator = "Administrator"; }}
这些常量将允许我们每次都使用强类型方法而不是文字字符串来引用我们的角色,从而防止人为错误。现在,我们可以编写代码来创建这些角色。因为我们谈论的是一个可能只执行一次的数据库种子任务,所以最好的地方是放在我们的 SeedController 中。但是使用现有的 Put 方法(我们在第 5 章中实现了该方法,将棋盘游戏数据插入数据库)将是一种不好的做法,因为它会破坏单一责任原则。相反,我们应该重构 SeedController 为我们希望它处理的两个任务创建不同的端点(和操作方法)。我们将重命名现有终结点 /Seed/BoardGameData,并为新的种子任务创建新的 /Seed/ AuthData。
重构种子控制器
若要重构种子控制器,请打开 /Controllers/SeedController.cs 文件,然后修改现有代码,如以下清单所示(更新的行以粗体显示)。
清单 9.11 /控制器/种子控制器.cs 文件:类重构
using Microsoft.AspNetCore.Authorization; ❶using Microsoft.AspNetCore.Identity; ❶ // ... existing codenamespace MyBGList.Controllers{ [Authorize] [Route("[controller]/[action]")] ❷ [ApiController] public class SeedController : ControllerBase { // ... existing code private readonly RoleManager<IdentityRole> _roleManager; ❸ private readonly UserManager<ApiUser> _userManager; ❹ public SeedController( ApplicationDbContext context, IWebHostEnvironment env, ILogger<SeedController> logger, RoleManager<IdentityRole> roleManager, ❸ UserManager<ApiUser> userManager) ❹ { _context = context; _env = env; _logger = logger; _roleManager = roleManager; ❸ _userManager = userManager; ❹ } [HttpPut] ❺ [ResponseCache(CacheProfileName = "NoCache")] public async Task<IActionResult> BoardGameData() ❺ { // ... existing code } [HttpPost] [ResponseCache(NoStore = true)] public async Task<IActionResult> AuthData() ❻ { throw new NotImplementedException(); } }}
❶ 必需的命名空间
❷ 新的基于属性的路由行为
❸ 角色管理器接口
❹ 用户管理器接口
❺ 现有看跌期权操作方法更名为棋盘游戏数据
❻ 新的身份验证数据操作方法
此代码应该易于理解。我们更改了控制器的路由规则,使端点与操作名称匹配;然后,我们注入了创建和分配角色所需的角色管理器和用户管理器 API。最后,我们重命名了现有的 Put 操作方法 BoardGameData,并添加了一个新的 AuthData 操作方法来处理角色创建任务。
请注意,我们没有实现新方法,而是专注于 SeedController 的重构部分。现在我们可以继续实现 AuthData 操作方法,将“未实现”代码替换为以下列表中的代码。
示例 9.12 /Controllers/SeedController.cs 文件: AuthData 方法
[HttpPost][ResponseCache(NoStore = true)]public async Task<IActionResult> AuthData(){ int rolesCreated = 0; int usersAddedToRoles = 0; if (!await _roleManager.RoleExistsAsync(RoleNames.Moderator)) { await _roleManager.CreateAsync( new IdentityRole(RoleNames.Moderator)); ❶ rolesCreated ; } if (!await _roleManager.RoleExistsAsync(RoleNames.Administrator)) { await _roleManager.CreateAsync( new IdentityRole(RoleNames.Administrator)); ❶ rolesCreated ; } var testModerator = await _userManager .FindByNameAsync("TestModerator"); if (testModerator != null && !await _userManager.IsInRoleAsync( testModerator, RoleNames.Moderator)) { await _userManager.AddToRoleAsync(testModerator,➥ RoleNames.Moderator); ❷ usersAddedToRoles ; } var testAdministrator = await _userManager .FindByNameAsync("TestAdministrator"); if (testAdministrator != null && !await _userManager.IsInRoleAsync( testAdministrator, RoleNames.Administrator)) { await _userManager.AddToRoleAsync( testAdministrator, RoleNames.Moderator); ❷ await _userManager.AddToRoleAsync( testAdministrator, RoleNames.Administrator); ❷ usersAddedToRoles ; } return new JsonResult(new { RolesCreated = rolesCreated, UsersAddedToRoles = usersAddedToRoles });}
❶ 创建角色
❷ 将用户添加到角色
正如我们所看到的,我们包括了一些检查以确保
- 仅当角色尚不存在时,才会创建角色。
- 仅当用户存在且尚未加入时,才会将用户添加到角色中。
如果多次调用操作方法,这些控件将防止代码引发错误。
提示Test管理员用户已添加到多个角色:审阅者和管理员。这对于我们的任务来说完全没问题,因为我们希望管理员拥有与版主相同的权限。
9.4.3 为用户分配角色
由于我们已经创建了 TestAdministratorator 和 TestAdministrator 用户,因此将他们分配给新角色将是一项简单的任务。我们需要在调试模式下启动我们的项目,访问 SwaggerUI,并执行 /Seed/AuthData 端点。
由于我们之前将 [Authorize] 属性设置为整个 SeedController,但是,如果我们尝试在没有有效 JWT 的情况下调用该终结点,我们将收到 401 – 未授权状态代码。为了避免这种结果,我们有两个选择:
- 使用 /Account/Login 端点对自己进行身份验证(任何用户都可以做到这一点),然后在 SwaggerUI 的授权弹出窗口中设置生成的 JWT。
- 在执行项目并调用 /Seed/AuthData 终结点之前,请注释掉 [Authorize] 属性,然后取消注释它。
无论我们采用哪种路线,假设一切顺利,我们都应该收到带有以下 JSON 响应正文的 200 – OK 状态代码:
{ "rolesCreated": 2, "usersAddedToRoles": 2}
我们已经成功创建了我们的角色,并将我们的用户添加到其中。现在,我们需要确保将这些角色放在持有者令牌中,以便授权中间件可以检查它们的存在并采取相应的行动。
9.4.4 向 JWT 添加基于角色的声明
若要将角色添加到持有者令牌,我们需要打开 /Controllers/AccountController.cs 文件,然后更新 Login 操作方法,为成功进行身份验证的用户所属的每个角色添加一个声明。以下代码片段演示了如何(粗体换行):
// ... existing code var claims = new List<Claim>();claims.Add(new Claim( ClaimTypes.Name, user.UserName));claims.AddRange( (await _userManager.GetRolesAsync(user)) .Select(r => new Claim(ClaimTypes.Role, r))); // ... existing code
如我们所见,经过身份验证的用户的 JWT 现在包含零个、一个或多个基于角色的声明,具体取决于用户所属的角色数量。这些声明将用于是否授权该用户的请求,具体取决于我们如何为每个控制器和/或操作方法配置授权规则。
9.4.5 设置基于角色的身份验证规则
现在,我们已确保经过身份验证的用户的 JWT 将包含其每个角色(如果有)的声明,我们可以更新现有的 [Authorize] 属性以考虑角色。让我们从主持人角色开始。打开 BoardGamesController、DomainsController 和 MechanicsController 文件,并按以下方式更改应用于其更新方法的现有 [Authorize] 属性:
[Authorize(Roles = RoleNames.Moderator)]
此代码将更改属性的行为。现在,该属性将仅授权具有审阅人角色的用户,而不是授权所有经过身份验证的用户,而不管其角色如何。由于我们要添加对 RoleNames 静态类的引用,因此还需要在每个控制器文件的顶部添加以下命名空间引用:
using MyBGList.Constants;
让我们对管理员角色重复此过程。通过以下方式更改应用于控制器删除方法的现有 [Authorize] 属性:
[Authorize(Roles = RoleNames.Administrator)]
然后打开 SeedController,并使用前面的属性更新其 [Authorize] 属性(我们将其应用于控制器本身),因为将该控制器限制为管理员是我们分配的一部分。
9.4.6 测试 RBAC 流
为了执行无害测试,我们可以使用要检查的授权规则创建两个新的最小 API 测试方法,而不是使用现有的端点,这会对我们的数据进行一些永久性更改。打开 Program.cs 文件,并在处理我们之前添加的 /auth/test/2 终结点的方法的正下方添加以下代码:
app.MapGet("/auth/test/2", [Authorize(Roles = RoleNames.Moderator)] [EnableCors("AnyOrigin")] [ResponseCache(NoStore = true)] () => { return Results.Ok("You are authorized!"); }); app.MapGet("/auth/test/3", [Authorize(Roles = RoleNames.Administrator)] [EnableCors("AnyOrigin")] [ResponseCache(NoStore = true)] () => { return Results.Ok("You are authorized!"); });
现在,我们可以执行以下测试周期,这与我们为第一个授权流设计的测试周期非常相似。在 SwaggerUI 主仪表板中,执行以下步骤:
- 使用 TestUser 的用户名和密码调用帐户/登录端点以接收有效的 JWT。
- 此用户不属于任何角色。
- 将 JWT 复制到剪贴板,单击 SwaggerUI 的授权按钮,将其值复制到弹窗中的输入文本框中,然后单击授权按钮关闭弹出窗口。
- 调用 /auth/test/2 和 /auth/test/3 终结点。
- 如果一切按预期工作,我们应该得到一个 401 – 未经授权的状态代码,因为这些端点仅限于版主和管理员,而 TestUser 不是其中之一,因为它没有相应的角色。
- 对测试审查器帐户重复步骤 1、2 和 3。
- 这一次,我们应该收到 /auth/test/200 终结点的 2 – OK 状态代码和 /auth/test/401 终结点的 3 – 未授权状态代码。前者仅限于版主(我们是),后者仅适用于管理员(我们不是)。
- 对 TestAdministrator 帐户重复步骤 1、2 和 3。
- 这一次,我们应该为两个终结点获取 200 – OK 状态代码,因为该帐户属于审阅者和管理员角色。
9.4.7 使用其他授权方法
正如我们在处理它时所看到的,我们实现的 RBAC 方法依赖于角色类型声明 (ClaimTypes.Role) 来执行其授权检查。如果 JWT 令牌包含此类声明,并且声明的内容与 [Authorize] 属性的要求匹配,则用户已获得授权。
但是,可以分配和检查许多其他声明类型,以确定用户是否获得授权。我们只能授权拥有手机号码的用户,例如,通过使用 ClaimTypes.MobilePhone,我们可以执行这样的检查,而不是用户给定的角色,或者除了用户给定的角色之外。
基于声明的访问控制
此方法称为基于声明的访问控制 (CBAC),包括 RBAC 提供的相同功能以及更多功能,因为它可用于同时检查任何声明(或声明集)。
注意我们可以说,RBAC 只不过是基于 ClaimTypes.Role 的单个特定声明的 CBAC 的高级抽象。
与 RBAC 不同,RBAC 由于 [Authorize] 属性的 Role 属性而可以轻松快速地实现,声明要求是基于策略的,因此必须通过在 Program.cs 文件中定义和注册策略来显式声明它们。下面介绍了如何添加“主持人使用移动电话”策略,该策略将检查主持人角色和移动电话号码是否存在:
builder.Services.AddAuthorization(options => { options.AddPolicy("ModeratorWithMobilePhone", policy => policy .RequireClaim(ClaimTypes.Role, RoleNames.Moderator) ❶ .RequireClaim(ClaimTypes.MobilePhone)); ❷});
❶ 检查具有给定值的索赔
❷ 仅检查声明是否存在
注意此技术与我们在第 3 章中用于注册 CORS 策略的技术大致相同。
将上述代码片段粘贴到 builder.service AddAuthentication 行下方的程序.cs文件中,以配置身份验证服务。然后我们可以通过以下方式将策略设置为 [Authorize] 属性的参数:
[Authorize(Policy = "ModeratorWithMobilePhone")]
此策略需要对现有代码的以下部分进行一些修改:
- RegisterDTO 类,允许注册用户添加其手机号码
- 帐户控制器的注册操作方法,用于将移动电话值保存在数据库中(如果存在)
- 帐户控制器的登录操作方法,用于有条件地将 ClaimTypes.MobilePhone 的声明添加到包含用户移动电话号码(如果存在)的 JWT 令牌
我不打算在本书中使用这种方法。我简要展示它只是因为它对于实现某些特定的授权要求很有用。
基于策略的访问控制
尽管CBAC比RBAC更通用,但它允许我们仅检查是否存在多个声明中的一个和/或其特定值。如果声明值不是单个值,或者我们需要更复杂的检查,该怎么办?我们可能希望定义一个策略,以仅授权年龄等于或大于 18 岁的用户,并且我们无法通过检查是否存在 ClaimTypes.DateOfBirth 声明或特定出生日期值来执行此操作。
每当我们需要执行此类检查时,我们都可以使用基于策略的访问控制 (PBAC) 方法,这是 Microsoft.AspNetCore.Authorization 命名空间提供的最复杂和最通用的授权方法。此技术类似于 CBAC,因为它还需要声明性方法,即在 Program.cs 文件中声明策略。但是,它不是仅仅检查一个或多个声明是否存在(以及可选的值),而是使用由一个或多个需求(IAuthorizationRequire)和需求处理程序(IAuthorizationHandler)组成的更通用的接口。
注意此接口也由 CBAC 的 RequireClaim 方法在后台使用。我们可以说,RBAC 和 CBAC 都是基于预配置策略的 PBAC 的简化实现。
我不打算在本书中使用 PBAC,因为它需要实现一些示例需求和需求处理程序类。但我将简要介绍 RequireAssertion 方法,这是一种使用匿名函数配置和构建基于策略的授权检查的便捷方法。以下是我们如何使用此方法定义“等于或大于 18”策略的方法:
options.AddPolicy("MinAge18", policy => policy .RequireAssertion(ctx => ctx.User.HasClaim(c => c.Type == ClaimTypes.DateOfBirth) && DateTime.ParseExact( "yyyyMMdd", ctx.User.Claims.First(c => c.Type == ClaimTypes.DateOfBirth).Value, System.Globalization.CultureInfo.InvariantCulture) >= DateTime.Now.AddYears(-18)));
添加的值是由 RequireAssertion 方法公开的 AuthorizationHandlerContext 对象,其中包含对表示当前用户的 ClaimsPrincipal 的引用。ClaimsPrincipal 类不仅可用于检查任何声明的存在和/或值,还可用于使用、转换和/或转换这些值以满足我们的所有需求。同样,此策略可用作某些假设 [Authorize] 属性的参数,以通过以下方式将某些控制器、操作方法和/或最小 API 方法限制为 18 岁以上的用户:
[Authorize(Policy = "MinAge18")]
此策略还需要对我们现有的代码进行大量重构,因为我们目前不询问(并收集)注册用户的出生日期。出于这个原因,我将在这里停止,将前面的代码仅供参考。
一些有用的授权相关参考
有关 [授权] 属性及其使用方法的其他信息,请参阅 http://mng.bz/Nmj2 中的指南。
要了解有关 RBAC、CBAC 和 PBAC 的更多信息,请查看以下指南:
- http://mng.bz/DZj9
- http://mng.bz/lJnM
- http://mng.bz/Bl8g
本节结束了我们的测试运行,以及我们进入 ASP.NET 核心身份验证和授权的旅程。重要的是要明白,我们只是触及了这些庞大而复杂的主题的表面。我们在本章中整理的示例源代码对于一些没有敏感或有价值数据的基本 Web API 来说可能已经足够了,但除非我们使用一些额外的安全措施来支持它,否则它可能不适合更复杂的方案。
至于 ASP.NET 核心身份,我们只是触及了框架可以做什么的表面,从PBAC到非JWT承载者,更不用说与第三方授权提供程序和协议(如OAuth2)的内置集成,由于空间原因,我没有处理。尽管如此,本章提供的广泛概述仍应有助于我们了解 Core 身份验证和授权的工作原理 ASP.NET 以及如何在典型的 Web API 方案中实现它们。
9.5 习题
将我们在本章中学到的知识印记下来的最好方法是用一些与标识相关的升级任务来挑战自己,我们的产品所有者可能希望分配给我们。与往常一样,练习的解决方案可以在GitHub上的/Chapter_09/Exercises/文件夹中找到。若要测试它们,请将 MyBGList 项目中的相关文件替换为该文件夹中的文件,然后运行应用。
9.5.1 添加新角色
使用强类型方法将新的“SuperAdmin”角色添加到我们用于定义角色名称的静态类中。然后修改种子控制器的 AuthData 方法,以确保将创建新角色(如果该角色尚不存在)。
9.5.2 创建新用户
使用帐户/注册端点创建新的“TestSuperAdmin”用户,就像我们对 TestUser、TestAdministratoror 和 TestAdministrator 用户所做的那样。随意选择您自己的密码(但请确保您会记住它以备将来使用)。
9.5.3 为用户分配角色
修改 SeedController 的 AuthData 方法,将审阅人、管理员和超级管理员角色分配给测试超级管理员用户。
9.5.4 实现测试端点
使用最小 API 添加新的 /auth/test/4 端点,并将其访问权限限制为具有超级管理员角色的授权用户。
9.5.5 测试 RBAC 流
使用帐户/登录终结点恢复 TestSuperAdmin 帐户的 JWT,并使用它来尝试访问 /auth/test/4 终结点,并确保新用户和角色按预期工作。
总结
- 身份验证是一种验证实体(或个人)是否是它(或他们)声称的机制。授权定义了实体(或个人)能够做什么。
- 这两个进程在任何需要限制对内容、数据和/或终结点的访问的 Web 应用或服务中都起着关键作用。
- 在大多数实现方法中,身份验证过程通常在授权过程之前发生,因为系统需要在分配其权限集之前标识调用客户端。
- 但是某些身份验证技术(如持有者令牌)强制实施自包含的授权机制,从而允许服务器授权客户端,而不必每次都对其进行身份验证。
- 持有者令牌的自包含授权方法在多功能性方面有几个优点,但如果服务器或客户端无法保护令牌免受第三方访问,则可能会引发一些安全问题。
- ASP.NET 核心标识框架提供了一组丰富的 API 和高级抽象,可用于在任何 ASP.NET 核心应用中管理和存储用户帐户,这使其成为在任何 ASP.NET 核心应用中实现身份验证和授权机制的绝佳选择。
- 此外,借助多个内置类、帮助程序和扩展方法,它可以轻松地与 EF Core 集成。
- ASP.NET 核心标识提供了多种执行授权检查的方法:
- RBAC,它易于实现,通常足以满足大多数需求。
- CBAC,实施起来稍微复杂一些,但用途更广,因为它可以用来检查任何索赔。
- PBAC是RBAC和CBAC使用的基础结构,可以直接访问以设置更高级的授权要求。
- 身份验证和授权是复杂的主题,尤其是从 IT 安全的角度来看,因为它们是黑客攻击、DoS 攻击和其他恶意活动的主要目标。
- 因此,应非常谨慎地使用本章中描述的技术,始终检查更新,并与 ASP.NET 核心社区和IT安全标准提供的最佳实践一起使用。