9 鉴权与授权 (Authentication 和 Authorization)
主要内容:
- 理解鉴权与授权
- 了解
ASP.NET Core Identity
- 利用用户账户 (
user account
) 与JSON Web Token
来实现鉴权 - 通过
AuthorizeAttribute
或IAuthorizationFilter
来启用授权 - 理解基于角色访问控制 (
RBAC
) 的授权策略
在之前的章节中介绍的内容没有进行访问限制, 也就是说或任何人都可以使用我们的控制器. 这样的应用部署在网络上是很危险的.
这是本章的重点, 使用 ASP.NET Core Identity 来实现管理用户, 角色, claims, 令牌, 策略与授权行为等.
9.1 基本概念
首先需要了解一些概念, 鉴权与授权常常出现在同一个上下文中, 但是它们是有精确定义的.
9.1.1 Authentication (鉴权)
在信息安全中, 鉴权表示验证计算机, 软件或用户的正确标识的行为. 简单的说, 鉴权就是在判断实体 (如, 用户) 的 claim 是什么.
claim 简单的理解为身份信息集合.
鉴权过程是比较重要的. 同时书中简要列举的鉴权的作用, 例如保证数据的安全, 允许识别用户的操作与数据等. 不仅针对用户的数据有安全保障, 也对系统有安全保障 (对一些操作可追溯).
同时介绍了今天常见的鉴权技术, 例如密码用户名, 手机号, 指纹等信息.
9.1.2 Authorization (授权)
一般来说, 授权表示可以执行的权利. 在 IT 领域, 表示系统是否识别特定软件, 用户 (组), 并允许访问.
这些任务通常通过访问策略, 声明或权限组的实现来处理. 这些访问策略, 声明或权限组允许或禁止给定逻辑空间(文件系统文件夹、驱动器网络、数据库、网站部分、web API端点等)中的每个相关操作或行为(读、写、删除等).
实际开发中, 授权通常通过定义一系列访问控制列表 (acl) 来实现允许或不允许.
- 特定资源被允许的访问类型 (读, 写, 删除 等)
- 计算机, 软件, 或用户 (组) 被允许或禁止
尽管授权是正交的 (jk: 这个不太理解), 并且不依赖于鉴权, 但是这两个概念总是交织在一起.
如果无法进行身份的识别, 就无法匹配对应的访问控制列表 (ACL), 也无法实现允许与禁止.
鉴于此, 大多数访问控制机制都设计成必须同时含有鉴权与授权. 更为精确的说, 遵循下面步骤:
- 给为授权用户提供可访问的公共页面全新. 例如登录页面, 模块, 表单等.
- 验证登录成功的用户.
- 检查其 ACL, 并为其分配适当的访问权限.
- 通过给定的权限, 来授权用户是否可以访问内容.
jk: 再次体现额英文词汇中的精确含义, 汉语中的意合特性.
下图模拟了过程:
图中的过程并非完全的逻辑, 仅仅是示意逻辑. 在实际中, 完全存在未授权用户访问受限资源的情况, 一般可以将其重定向到授权页面.
结合所有, 即可看到鉴权与授权是不同的, 只要存在一个 ACL 的映射关系, 两者完全可以独立开来.
下面就需要考虑如何实现一个鉴权与授权的机制了. 按照传统的处理方式是提供一个登录页面, 登录就是在进行鉴权, 然后可以获得权限, 即可进行受限资源的访问.
但是 HTTP 是无状态的, 即不会保留上一次请求与响应的数据.
暂不考虑缓存等机制.
那么如何在请求过程中保存授权信息是关键.
实现方法
常见的实现方式包括, session/cookie
, bearer token
, API key
, 签名, 和证书.
- Session/Cookie 简要介绍了其特点与运作流程
- 是键值对, 保存授权信息
- 存储与服务器中
- 生成随机表示 SessionID, 以 cookie 形式发回浏览器
- 浏览器每次请求会带上 SessionID
- Bearer Token 简要介绍了其执行流程
- 它包含授权信息, 是通过服务器进行加密生成的字符串
- 该串会发会浏览器, 并在后续的请求中被使用, 直至过期
- 该串会存于 Authorization 请头中
- API Key 它是由服务器为客户端提供的 (或生成的) 客户端 Id 和 秘钥对.
- 这个对会以 AUthorization 的请求头的形式发送给服务器.
- 每次 API 访问都会在服务器中校验.
- 签名或证书
- 这两个技术使用预先存储的私钥, 或传输层安全 (TLS) 证书对请求数据进行散列处理.
- 这个方法可以避免中间人攻击等安全隐患, 但实施成本较高. 一般用于较高安全性的数据传输.
这些方法需要访问我们的 API, 需要考虑得失:
- Session/Cookie 不被考虑, 因为会对 RESTFul 有影响 (第3章介绍的无状态约束)
- Bearer Token. 安全性高, 也易使用. 并且 ASP.NET Core Identity 几乎 开箱即用.
- API Key 更安全. 但是附加工作量大.
- 签名或证书更安全, 但同样是附加工作量大, 并会一些时效性会有一定响应.
下面考虑 Bearer Token 的机制.
作者做了一些补充, 如果存在敏感的一些数据, 还是需要提升安全级别.
Bearer Token
基于 token 的鉴权 (或成为 bearer 鉴权) 是 WebAPI 中常用的鉴权方式. 它提供了可接收的安全标准, 同时不会破坏无状态 REST 约束.
基于 Token 的鉴权依旧需要鉴权 (authentication), 例如通过用户名密码登录. 在登录后不再使用持久连接. 而是生成一个加密的 authorization token.
authorization token 中包含
- 用户标识, 如 userId
- 连接客户端信息
- 以及过期时间等
token 发送到浏览器后, 可以设置到 Authorization 请求头中被后续请求所使用.
请求过程简述图:
OAuth2.0 中的细节会更多一些.
可以发现:
- 服务器不再存储数据.
- 客户端略有不同, 该数据可以存储在本地, 或仅使用依次后丢掉.
token 是自描述的, 也就是说其中可以包含需要的身份信息, 角色信息等. 甚至是不进行登录, 只要有 token 就可以对对应资源进行访问.
这里存在一个安全漏洞, 即令牌一旦办法, 就无法撤销. 在令牌被盗用后, 也无法解决. 最好的处理办法是尽可能减少令牌生效时间.
基本概念介绍完了, 下面开始要了解 ASP.NET Core Identity 了.
9.2 ASP.NET Core Identity
ASP.NET Core Identity 提供了一套抽象的 API 集, 用于在任何 ASP.NET Core 项目中管理与存储用户账户信息.
在这个期间会使用到数据库, 但是微软已经提供了对应的 EFcore 的相关类来实现.
ASP.NET Core Idnetity 是开源项目. GitHub
下面使用 Identity 来控制访问我们的项目 MyBGLIst. 我们先要进行:
- 安装需要 Nuget 包.
- 创建新的
MyBGListUser
实体类来处理用户数据, 例如 用户名与密码. - 更新
ApplicationDbContext
类, 来支持新的实体. - 添加新的数据库迁移, 并更新数据库.
- 在
Program.cs
文件中添加 Identity 的中间件与服务. - 实现新的控制器来处理注册过程与登录过程.
9.2.1 安装 Nuget 包
添加 Idnetity 功能到我们的项目中需要安装下面的包:
Microsoft.Extension.Identity.Core
. 包含各种登录所需要的关系系统与服务等.Microsoft.AspNetCore.Identity.EntityFrameworkCore
. 提供 EFCore 的库.Microsoft.AspNetCore.Authentication.JwtBearer
. 处理 Json Web Token (JWT) 的库.
与往常一样, 安装这些包可以在 VS 的包管理器中完成, 也可以基于 dotnet 命令行中完成.
> 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 创建 User
实体
下面创建新的实体类 ApiUser
.
理想情况下使用
User
最好, 但是为了避免可能存在的命名冲突, 建议命名时选择更加具有可区分性的名字.
由于使用 ASP.NET Core Identity, 最佳的方式是基于该框架所提供的 IdentityUser
类来进行派生. 该类位于 Microsoft.AspNetCore.Identity
命名空间下.
using Microsoft.AspNetCore.Identity;
namespace MyBGList.Models
{
public class ApiUser : IdentityUser
{
}
}
然后我们不用做任何事情, 因为 IdentityUser
中已经包含我们所需要的一切: UserName
和 Password
.
由于篇幅的原因, 不会对该类进行深入的描述, 细节可以参考文档.
然后需要更新 ApplicationDbContext
.
9.2.3 更新 ApplicationDbContext
第4章所创建的 ApplicationDbContext
派生自 DbContext
. 现在要使用 Identity 框架的功能, 我们将基类修改为 IdentityDbContext
.
该类位于 Microsoft.AspNetCore.Identity.EntityFrameworkCore
中.
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
// ... existing code
public class ApplicationDbContext : IdentityDbContext<ApiUser>
修改的有两个地方:
- 引入命名空间
- 基类从
DbContext
修改为IdentityDbContext<ApiUser>
.
需要注意的是, IdentityDbContext<TUser>
需要一个 TUser
, 这个 TUser
必须是 IdentityUser
类型的.
9.2.4 添加并应用新的迁移
dotnet ef migrations add Identity
dotnet ef database update Identity
如果没有问题会在命令行中提示成功, 我们可以通过 SSMS 来进行检查
ASP.NET Core Identity 的默认行为是让所有的 Identity 的数据库表都有 AspNet 前缀, 以便更容易从所有表中分辨出它来.
迁移可以保留每一个数据库变更的步骤, 这是 EFCore 的一个特性, 但是需要保留所有的迁移问题. 一旦丢失了部分该文件, 则会出现问题. 如果要恢复, 可能不得不重新初始化数据库. 这样被称为扁平化, 可以参考文档.
如果我们希望修改表名, 可以在 ApplicationDbContext
的 OnModelCreating
方法中来实现 (但不建议这么做):
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");
这段代码会将默认的前缀修改为 Api
.
作者仅仅是描述, 并未在自己的代码中使用这段内容.
9.2.5 设置服务与中间件
我们需要:
Identity
服务, 来处理注册与登录过程.Authorization
服务, 定义发布与读取 Jwt 的规则.Authentication
中间件, 添加读取 JWT 的读取任务到管道中.
下面从 Identity 服务开始.
添加 Identity 服务
操作步骤:
- 添加 ASP.NET Core Identity 服务到服务容器中
- 为密码配置最小安全需求 (即所谓的密码强度)
- 添加 authentication 中间件
打开 Program.cs
文件, 并找到添加数据库上下文的位置, 添加下面代码:
using Microsoft.AspNetCore.Identity; // 引入命名空间
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection"))
);
builder.Services.AddIdentity<ApiUser, IdentityRole>(options => // 添加 Identity 服务
{
options.Password.RequireDigit = true; // 密码强度配置, 含数字
options.Password.RequireLowercase = true; // 密码强度配置, 含小写字母
options.Password.RequireUppercase = true; // 密码强度配置, 含大写
options.Password.RequireNonAlphanumeric = true; // 密码强度配置, 含非字母字符
options.Password.RequiredLength = 12; // 密码强度配置, 长度最少 12
})
.AddEntityFrameworkStores<ApplicationDbContext>();
主要变化:
- 添加的命名空间
- 添加了 Identity 服务
- 配置密码强度
在非敏感数据的环境下, 上述要求已经很好了. 下一步是设置 authentication 服务.
添加 Authentication 服务
当前场景下, Authentication 服务的目标是:
- 将 JWT 作为默认的鉴权方法
- 启用 JWT Bearer 鉴权方法
- 设置 JWT 校验, 发布, 以及生命周期的配置
使用下面代码:
using Microsoft.AspNetCore.Authencation.JwtBearer;
using Microsoft.IdentityModel.Tokens;
builder.Servers.AddAuthencation(options => {
options.DefaultAuthenticateScheme =
opts.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"])
)
};
});
涉及修改:
- 引入命名空间
- 添加 Authentication 服务
- 设置默认的授权方案
- 添加 JWT 鉴权方案
- 配置 JWT 选项
JWT Bearer选项部分决定了如何验证身份令牌.
从代码中可以看到, 首选需要验证 Issuer
, Audience
, 以及用于发布令牌签名所用的 Key (IssuerSigningKey
).
执行这些校验会降低安全风险. 注意到这里作者并没有硬编码, 而是引用了配置文件.
更新 AppSettings.json
文件
打开 appSettings.json
文件, 在顶层节点, SeriLog
节点后添加下面内容:
"JWT": {
"Issuer": "MyBGList",
"Audience": "MyBGList",
"SigningKey": "MyVeryOwnTestSigningKey123$"
}
注意, 如果是要发布应用程序, 确保这些值需要更改.
最好移除 SigningKey
以确保安全性.
已经完成服务的配置, 下面是鉴权中间件.
添加 Authentication 中间件
找到 Program.cs
文件中已存在的授权中间件行. 然后在其前方添加鉴权中间件:
app.UseAuthentication(); // 新加入的鉴权中间件
app.UseAuthorization(); // 已存在的授权中间件
注意, 如同 ch2 中介绍的, 中间件的顺序很重要. 其决定了处理流程. 请确保两个中间件的顺序, 先鉴权, 再授权. 因为我们必须知道鉴权的方案以及处理函数, 才可以进行授权.
现在已经完成 Identity 的配置, 下面准备实现方法来完成注册与授权的处理.
9.2.6 实现 AccountController
本节, 我们会创建 AccountController
并提供动作方法: Register
(用于创建用户) 和 Login
(用于鉴权所创建的用户).
两个方法都需要必要的参数来进行处理. 例如:
- 注册方法需要用户名, 密码, 邮箱等数据.
- 登录仅需要用户名与密码.
由于 AccountController
需要使用 Identity 相关任务, 因此它需要注入下列服务 (我们之前从未使用过的):
UserManager
提供管理用户的 APISignInManager
提供用户登录的 API
两个服务均位于 Microsoft.AspNetCore.Identity
命名空间下.
考虑到需要在代码中处理 JWT 配置, 因此还需要 IConfiguration. 通常这些服务可以利用 DI 注入进来.
在 Controllers
目录下, 创建文件 AccountController.cs
文件.
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; // ❶ ASP.NET Core Identity 命名空间
using Microsoft.IdentityModel.Tokens; // ❶ ASP.NET Core Identity 命名空间
using System.IdentityModel.Tokens.Jwt; // ❶ ASP.NET Core Identity 命名空间
using System.Security.Claims; // ❶ ASP.NET Core Identity 命名空间
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; // ❸ 使用 UserManager API
private readonly SignInManager<ApiUser> _signInManager; // ❹ 使用 SignInManager API
public AccountController(
ApplicationDbContext context,
ILogger<DomainsController> logger,
IConfiguration configuration,
UserManager<ApiUser> userManager, // ❸ 使用 UserManager API
SignInManager<ApiUser> signInManager) // ❹ 使用 SignInManager API
{
_context = context;
_logger = logger;
_configuration = configuration;
_userManager = userManager; // ❸ 使用 UserManager API
_signInManager = signInManager; // ❹ 使用 SignInManager API
}
[HttpPost]
[ResponseCache(CacheProfileName = "NoCache")]
public async Task<ActionResult> Register() // ❺ 注册方法
{
throw new NotImplementedException();
}
[HttpPost]
[ResponseCache(CacheProfileName = "NoCache")]
public async Task<ActionResult> Login() // ❻ 登录方法
{
throw new NotImplementedException();
}
}
}
注意到控制器中使用了 Route
特性, 并定义了 Controller/action
规则的路由, 因此有了下面的端点:
/Account/Register
/Account/Login
然后注入了许多服务 (略)
实现 Register
方法
查看 Identity 为我们所创建的数据库表 [AspNetUsers]
, 我们可以看到创建实体需要的参数.
该表存储之前所创建的 ApiUser
实体的数据, 该类是 IdentityUser
的派生类.
下面我们需要对应的 DTO 对象. 在项目目录 DTO
下创建 RegisterDTO.cs
文件.
为了演示简单, 仅提供三个数据:
- 用户名
- 用户密码
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 了, 下面开始实现 AccountController.Register
方法, 我们需要处理下面任务:
- 接收
RegisterDTO
输入 - 检查
ModelState
来确保输入是有效的 - 如果
ModelState
是有效的, 创建新用户 (登录的结果), 并返回Status Code 201 - Created
; 否则, 返回Status Code 400 - Bad Request
文档错误. - 如果创建用户失败, 或在整个过程中存在异常, 则返回
Status Code 500 - Internal Server Error
, 以及相关的错误消息.
下面为实现过程:
[HttpPost]
[ResponseCache(CacheProfileName="NoCache")]
public async Task<ActionResult> Register(RegisterDTO input) {
try {
if (ModelState.IsValid) { // 检查 ModelState 以及相关行为
var newUser = new ApiUser();
newUser.UserName = input.UserName;
newUser.Email = input.Email;
var result = await _userManager.CreateAsync(newUser, input.Password); // 创建用户
if (result.Successed) { // 检查新建结果
_Logger.LogInfo("已创建用户 {userName}({email}).", newUser.UserName, newUser.Email);
return StatusCode(201, $"已创建用户 '{ newUser.UserName }'.")
} 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 /Account/Register
.
依次测试邮箱格式不正确, 密码强度不高的参数. (略)
注意, 密码不会存储明文, 而是会将密码散列为不可逆的字符串, 存储于 [AspNetUsers].[PasswordHash]
中.
已经完成注册, 下面看看 Login
.
使用 Login
方法
我们的任务是创建一个 LoginDTO
然后实现 Login
方法. 现在我们应该知道, 只需要两个属性 UserName
和 Password
.
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
方法:
- 接收
LoginDTO
输入 - 检查
ModelState
来确保输入正确. 否则返回Status Code 400 - Bad Request
. - 如果用户存在, 并且密码正确, 生成一个新的 Token, 并返回给用户, 以及
Status Code 200 - OK
. - 如果用户不存在, 或密码错误, 或在处理过程中出现异常, 则返回
Status Code 401 - Unauthorized
, 以及相关错误消息.
下面为参考代码:
[HttpPost]
[ResponseCache(CacheProfileName = "NoCache")]
public async Task<ActionResult> Login(LoginDTO input)
{
try {
if (ModelState.IsValid) { // 检查 ModelState 以及相关行为
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>(); // 设置用户 Claim
claims.Add(new Claim(ClaimTypes.Name, user.UserName));
var jwtObject = new JwtSecurityToken( // 实例化 JWT 对象
issuer: _configuration["JWT:Issuer"],
audience: _configuration["JWT:Audience"],
claims: claims,
expires: DateTime.Now.AddSeconds(300),
signingCredentials: signingCredentials
);
var jwtString = new JwtSecurityTokenHandler() // 生成 JTW 加密串
.WriteToken(jwtObject);
return StatusCode( // 返回 JWT
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 创建过程中担任不同角色:
signinCredentials
该变量存储 JWT 签名, 它采用 HMAC SHA-256 加密算法加密. 注意SigningKey
来自配置文件, 与之前Program.cs
文件中Authorization
服务所使用的配置文件一样. 该方法确保读与写所使用的Key
是一样的.claims
该变量存储了用户的元数据信息, 并将其存储与 JWT 中. 在授权 (authorization) 过程中会使用该数据来判断用户是否允许访问某些资源 (接下来就会处理这个过程). 此时我们可以看到我们存储了UserName
属性, 后面还会存储更多的属性.jwtObject
该变量存储jwt
实例 (以C#
对象的形式), 其中存储了签名凭据, 用户 claims, 以及从配置中来的issuer
, 和audience
, 以及生命周期 (300 秒) 的数据.jwtString
该变量存储表示 JWT 的加密后的字符串. 该字符串会发送给客户端, 并通过客户端的Authorization
请求头, 在后续请求中发回服务端.
方法处理完成, 下面进行测试.
鉴权测试用户 (Authenticating the test user)
与 Register
方法一样, 在 Swagger 中进行测试, 如果成功会获得 200 的响应状态码, 并返回一个 JWT 字符串.
9.3 授权设置 (Authorization Settings)
下面我们会使用 Login
方法生成的 JWT 来约束我们现有的 API. 要达到这个目的, 我们有两地方需要关注:
- 客户端. 添加 Authorization HTTP 请求头. 这里测试我们使用 SwaggerUI.
- 服务端. 设置 Authorization 规则来使得我们现有的控制器 (以及 Minimal API) 的动作方法, 仅由验证含有必须的 claim 的 JWT 后才可以调用.
9.3.1 添加 authorization HTTP 头
由于 JWT 是一个字符串, 我们需要修改 SwaggerUI 来启用它.
一般客户端的库都有相关的拦截方法来处理请求头. 这里给出 ng 与 axios 的文档链接:
- angular. 内置了: https://angular.io/api/common/http/HttpInterceptor
- axios. 应用于如 react 等其他库: https://axios-http.com/docs/interceptors
我们需要一个安全定义来告知 Swagger 保护我们的 API.
using Microsoft.OpenApi.Models; // 需要的命名空间
// ...
builder.Services.AddSwaggerGen(options => {
options.ParameterFilter<SortColumnFilter>();
options.ParameterFilter<SortOrderFilter>();
options.AddSecurityDefinition("Bearer", new OpenApiSecuritySchema { // Swagger 安全定义
In = ParameterLocation.Header,
Description = "请输入 Token",
Name = "Authorization",
Type = SecuritySchemaType.Http,
BearerFormat = "JWT",
Schema = "bearer"
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement { // Swagger 安全需求
{
new OpenApiSecuritySchema {
Reference = new OpenApiReference {
Type = ReferenceType.SecuritySchema,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
})
重启后, 在 Swagger 页面的右上角会出现一个带锁的按钮, 点击后可以输入 JWT, 并在后续的请求中附加到 Authorization Header 中.
客户端配置可以完成, 下面开始处理服务端.
9.3.2 设置 [authorize]
特性
我们应该分配, 哪些接口可以随意使用, 哪些接口必须限制. 一般读取的接口公开, 而修改新增等接口需要保护起来.
假定除了下面方法我们不限制接口的访问:
BoardGamesController
-Post
,Delete
DomainsController
-Post
,Delete
MechanicsController
-Post
,Delete
SeedController
-Put
可见所有这些方法都是要操作数据库的, 因此将其放在授权规则之后更有意义. 我们不希望匿名用户对数据进行编辑和删除.
要实现我们的计划, 可以将 Microsoft.AspNetCore.Authorization
命名空间下的 [Authorize]
特性置于需要处理的方法之上.
该特性可用于控制器, 动作方法, 以及 minimal API
方法上.
可配置基于鉴权方案, 策略, 角色的鉴权规则, 这些规则可以在特性参数中进行设置 (即将实现).
不带参数的特性, 即 [Authorize]
是基本形式. 它会限制授权用户的访问, 而不考虑其权限.
由于我们还未定义角色与策略, 因此可以使用无参数的特性来开启我们的实现之旅.
打开下面的控制器: BoardGamesController
, DomainsController
, MechanicsController
, 以及 SeedController
.
然后将 [Authorize]
特性添加到其中 Post
, Delete
, 以及 Put
方法上. 例如:
using Microsoft.AspNetCore.Authorization; // 需要的命名空间
// ...
[Authorize] // Authorize 特性
[HttpPost(Name = "UpdateBoardGame")]
[ResponseCache(CacheProfileName = "NoCache")]
public async Task<RestDTO<BoardGame?>> Post(BoardGameDTO model)
此时已经配置完成.
选择默认访问行为
理解下面的逻辑比较重要:
- 在需要的方法上使用
[Authorize]
表示除了标记了[Authorize]
特性的方法, 都可以随意的不受限制的访问. - 也可以使用反模式, 在控制器中使用
[Authorize]
, 这样所有的方法都需要授权才可以使用, 然后在需要不受限的方法上标记[AllowAnonymous]
.
这两种方式都可以使用, 置于怎么选择, 根据开发者的需求来确定.
在作者书中案例中, 为了简单, 一般采用需要限制时, 就加上 [Authorize]
特性. 而 SeedController
是一个例外, 将 [Authorize]
置于控制器上. 即:
[Authorize]
[Route("[controller]")]
[ApiController]
public class SeedController : ControllerBase
至此, 控制器与动作方法便会自动对请求进行验证, 我们不用处理其他任何动作.
启用 Minimal API 中的授权
在进入测试阶段之前, 我们来看看在 Minimal API 中如何启用授权 (因为没有控制器了).
实际上就是将特性加到方法 (Lambda 表达式) 上:
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 测试授权流程
实际上就是使用 Swagger 来跑一边接口流程. (略)
9.4 基于角色的访问控制
我们需要实现不同访问控制, 需要实现下面的鉴权类型:
- 基础用户 - 提供基本的只读端点的访问, 相当于匿名(未注册)用户的访问.
- 版主 - 提供只读端点, 以及更新端点的访问, 不提供删除与新增.
- 管理员 - 可以执行任意权限内容 (CRUD).
现在的代码相当于是管理员, 即只要登录就可以做所有的事情. 要实现分级别的权限控制, 可以使用基于角色的访问控制.
基于角色的访问控制 (RBAC) 是一种内置的鉴权策略, 可以为不同用户分配不同权限. 角色类似于一个组, 可以将用户挂载该组中, 然后用组来限制接口的访问.
我们需要实现的是:
- 注册其他用户. 这里使用
TestModerator
和TestAdministrator
来表示. - 预先创建一组角色. 基于我们的需求, 使用
Moderator
和Administrator
. 我们不需要为基础用户创建角色, 只要不添加[Authorize]
即可实现. - 将用户添加到角色中. 简单的说, 将
TestModerator
添加到Moderator
中, 将TestAdministrator
添加到Administrator
. - 将角色声明添加到 JWT 中. 因 JWT 中包含授权用户的 claims 信息, 我们需要将角色信息也添加进去. 这样 Authorization 中间件才可以读取其信息, 以及相关行为.
- 设置基于角色的授权规则. 我们可以通过更新
[Authorize]
来实现. 因此我们需要 JWT 中的角色 claim.
9.4.1 注册新用户
这一步很简单, 只需要调用 /Account/Register
方法注册两个测试用户即可.
{
"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"));
后续会使用角色名来引用该角色, 建议作为字符串的角色名定义为常量.
添加角色名常量
在 Constants
目录中创建 RoleNames.cs
文件, 添加下面代码:
namespace MyBGList.Constants {
public static class RoleNames {
public const string Moderator = "Moderator";
public const string Administrator = "Administrator";
}
}
作者介绍了这个方式与直接硬编码字面量的优势. 避免人为的编码错误.
创建角色属于一次性过程, 可以将其放在 SeedController
的方法中.
重构 SeedController
需要处理的是:
- 修改已存在的
/Seed/BoardGameData
端点的名字 - 创建新的端点
/Seed/AuthData
using Microsoft.AspNetCore.Authorization; // 引入必须的命名空间
using Microsoft.AspNetCore.Identity; // 引入必须的命名空间
// ...
namespace MyBGList.Controllers {
[Authorize]
[Route("[controller]/[action]")] // 更新路由方案
[ApiController]
public class SeedController: ControllerBase {
// ...
private readonly RoleManager<IdentityRole> _roleManager;
private readonly UserManager<ApiUser> _userManager;
public SeedController(
// ...,
RoleManager<IdentityRole> roleManager,
UserManager<ApiUser> userManager
) {
// ...
_roleManager = roleManager;
_userManager = userManager;
}
[HttpPut]
[ResponseCache(CacheProfileName = "NoCache")]
public async Task<IActionResult> BoardGameData() { // 对已存在的 Put 方法更名
// ...
}
[HttpPost]
[ResponseCache(NoStore = true)]
public async Task<IActionResult> AuthData() { // 新增方法
throw new NotImplementedException();
}
}
}
作者对代码做了解释 (略)
下面开始实现 AuthData
方法
[HttpPost]
[ResponseCache(NoStore = true)]
public async Task<IActionResult> AuthData() {
int rolesCreated = 0;
int userAddedToRoles = 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);
userAddedToRoles++;
}
var testAdministrator = await _userManager.FindByNameAsync("TestAdministrator");
if (testAdministrator != null && !await _userManager.IsInRoleAsync(testAdministrator, RoleNamesAdministrator)) {
await _userManager.AddToRoleAsync(testAdministrator, RoleNames.Moderator);
await _userManager.AddToRoleAsync(testAdministrator, RoleNames.Administrator);
userAddedToRoles++;
}
return new JsonResult(new {
RolesCreated = rolesCreated,
UsersAddedToRoles = usersAddedToRoles
});
}
作者简要解释了一下代码. 重点说明了如果重复调用不会出现错误.
这里需要注意的是 Administrator 添加了两个角色.
9.4.3 为用户分配角色
实际上就是在 Swagger 中运行该代码, 调用接口即可完成初始化.
需要注意的是, [Authorize]
特性, 因此需要登录才可以调用.
9.4.4 将角色 Claims 添加到 JWT 中
更新 Account/Login
方法.
// ...
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))); // 附加用户角色
// ...
代码更新后, 每一个 JWT 中的用户信息会具有 0 个, 1 个或 多个 角色信息.
9.4.5 设置基于角色的认证规则
现在将所有使用 [Authorize]
的地方修改为:
[Authorize(Roles = RoleNames.Moderator)]
注意引入常量命名空间. 这样所有的方法必须在用户具有 Moderator
角色的时候才可以访问.
然后将删除的方法, 以及 SeedController
上的 [Authoraze]
特性修改为:
[Authorize(Roles = RoleNames.Administrator)]
9.4.6 测试 RBAC 流程
直接从接口上进行测试会影响到我们现有的数据, 因此可以创建两个 Minimal Api 来进行测试
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!");
});
然后利用 Swagger 进行测试. 步骤为
- 先登录, 获得 JWT
- 将 JWT 设置到请求头中
- 依次执行
/auth/test/2
和/auth/test/3
方法 - 重复上述过程, 使用不同账户, 包括不登录
9.4.7 使用可选的授权方法
use alternative authorization methods
此时采用角色进行校验, 只要 JWT
中的 Claims
中含有对应的角色, 并在 [Authorize(Roles = )]
中匹配到了对应角色就表示已授权.
除了使用角色, 还可以使用其他方式进行授权, 如 ClaimTypes
枚举中描述的. 例如使用 ClaimTypes.MobilePhone
来补充授权规则.
基于 Claim 的访问控制
该方法被称为 claims-based access control (CBAC). 它具有与 RBAC 类似的功能.
基于 ClaimTypes 的逻辑的抽象是一样的.
基于角色的使用很普遍, 是默认启用的. 如果要支持角色与电话, 需要手动配置.
下面添加一个 ModeratorWithMobilePhone
的规则, 来同时校验 Moderator
角色与电话号码.
builder.Services.AddAuthorization("ModeratorWithMobilePhone", policy => {
policy
.RequireClaim(ClaimTypes.Role, RoleNames.Moderator)
.RequireClaim(ClaimRtpes.MobilePhone)
});
注意, 该技术与ch3中所使用的 CORS 技术类似.
将该代码拷贝到 Program.cs
文件的 builder.Services.AddAuthentication()
方法后面, 来配置授权服务.
然后将需要应用该授权方案的 [Authorize]
特性修改为:
[Authorize(Policy = "ModeratorWithMobilePhone")]
然后还需要对现有代码进行下面改动:
- 修改
RegisterDTO
, 来允许MobilePhone
属性. - 修改
Register
方法, 来将MobilePhone
存储到数据库中, 如果有的话. - 修改
Login
方法, 将MobilePhone
添加到Claim
中, 然后将其添加到 JWT 中, 如果有MobilePhone
的话.
作者只是简要的说明这个实现的步骤, 仅为需要特定授权的时候可以使用.
基于策略的访问控制
尽管 CBAC 比起 RBAC 更加的多样化. 它允许我们校验多个 Claim 中的一个, 并且/或某个取值.
但是 claim 中有多个取值, 或更加复杂的校验呢?
例如, 我们需要定义一个策略, 来校验用户的年龄等于或大于 18 岁. 但是我们无法使用 Claim.DateOfBirth
或制定一个 date-of-value
来完成这个校验.
但凡我们需要完成这样的校验, 我们可以使用基于策略的访问控制 (PBAC) 方法, 它是 Microsoft.AspNetCore.Authorization
中最复杂, 也是功能最多的方法.
该技术与 CBAC 类似, 因为它依旧需要声明. 在 Program.cs
文件中声明策略. 它不是检查一个或多个 Claim, 而是由一个或多个处理需求 (IAuthorizationRequirement
), 或处理需求的处理程序 (IAuthorizationHandler
) 组成的接口来处理.
注意, 这些接口也在 CBAC 的 RequireClaim
方法的底层被使用. 因此可以将 RBAC 和 CBAC 称为 PBAC 的简化版本.
作者不会在本书中介绍 PBAC 的处理, 主要是实现相关接口类会非常繁琐.
作者会简要介绍 RequireAssertion
方法. 该方法使用匿名方法, 简单快速地配置基于策略的授权方法.
此时作者演示检查大于或等于 18 岁的数据.
options.AddPolicy("MinAge", 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
的引用, 用于描述当前用户.
ClaimsPrincpal
不仅可以校验一个或多个 Claim
数据, 还可以处理其包含的其他数据.
然后在需要使用该策略的位置, 使用:
[Authorize(Policy = "策略的名字")]
此时如果需要继续, 也需要修改代码. 因为前面的代码中我们并没有提供用户的出生日期的数据.
所以作者不打算继续下去了. 然后作者给出了一些参考连接:
作者解释关于鉴权与授权, 以及 Identity 框架, 我们仅仅接触到了冰山的一角. 今后还有更加深入的内容.
9.5 练习
答案可以在 GitHub 的仓库中找到.
检验学习的成果, 就是实践.
9.5.1 添加一个新的角色
在静态类中, 添加 SuperAdmin
角色名, 然后修改 AuthData
方法来创建角色.
9.5.2 创建一个用户
使用 Account/Register
端点, 添加用户 TestSuperAdmin
. 并进行测试.
9.5.3 将用户添加到角色中
9.5.4 实现测试端点
9.5.5 测试 RBAC 流程
练习与前面的内容流程几乎一样.
9.6 小结
- 鉴权 (Authentication) 是一种校验用户是谁, 或其 Claim 的数据是什么的机制. 而授权 (Authorization) 是用于判断实体可以做什么的方法.
- 在大多数实现方法中, 身份验证过程通常发生在授权过程之前, 因为系统需要在分配其权限集之前识别调用客户端.
- Identity 框架提供了丰富的 API 可以帮助我们来实现鉴权与授权.
- Identity 框架还提供了丰富的策略方法.
- 鉴权与授权是一个综合内容.