ch8 添加鉴权与授权
Adding Authentication and Authorization
首先说明鉴权与授权会交替使用, 但是概念并不一样.
鉴权可以运行在很多不同的地方. 例如本地账户登录, 第三方提供, 甚至是 Identity Server 以及 Okta.
还会涉及到登录二次验证, 以及刷新 token.
本章重点围绕鉴权和授权如何在 Minimal API 中实现.
本章要点:
- 鉴权与授权简介
- 保护 Minimal API
- 处理授权 - 基于角色与策略
技术要求
略
鉴权与授权简介
鉴权授权一般会交织在一起, 但逻辑上是有区别的
- 鉴权 Authentication 是验证的过程. 用户是谁.
- 授权 Authorization 是对验证后的用户提供可以做什么的任务.
因此授权应在鉴权之后.
然后作者例举了登机的案例作为描述说明: 领取登机牌, 与登机的两个阶段类比于 鉴权 与 授权 两个阶段.
鉴权与授权在 ASP.NET Core 中由中间件来实现. 而且在 Minimal API 和基于 Controller 的接口中的用法一样.
它会基于用户身份, 角色, 策略 等来限制对端点的访问.
微软官方文档:
保护 Minimal API
要保护 Minimal API, 即要正确设置 Authentication 和 Authorization.
有很多的鉴权方案可适配现代应用. 在 Web 应用中, 通常会使用 Cookie.
但是在涉及 WebAPI 的时候, 我们会使用 API Key, 基础鉴权, 以及 JSON Web Token (JWT).
JWT 是很好的解决方案, 并在后续章节中我会将重点放在 JWT 上.
要深入 JWT 的概念, 可以参考 https://jwt.io/introduction
要在 JWT 中使用鉴权与授权, 首先安装 Microsoft.AspNetCore.Authentication.JwtBearer
Nuget 包.
- 可在 VS 的包管理工具中安装
- 也可以基于命令行:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
现在, 需要将鉴权与授权添加到 service provider 中. 以便在 DI 中使用.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationSchema).AddJwtBearer();
builder.Services.AddAuthorization();
这是最少的代码, 并不是实际上使用的代码. 因为还有很多相关数据与配置没有包含其中.
代码中:
AddAuthentication(JwtBearerDefaults.AuthenticationSchema)
表示采用JwtBearer
作为鉴权方案.- 它表示请求头中应该含有
Authorization: Bearer <Token>
的内容.
- 它表示请求头中应该含有
AddJwtBearer()
告知 AspNetCore 解析 Jwt 的格式.AddAuthorization()
表示启用授权.
然后将鉴权授权中间件添加到管道中, 来启用鉴权与授权功能
app.UseAuthentication();
app.UseAuthorization(); // 注意, 授权一定在鉴权之后.
最终, 我们可以使用 [Authorize]
特性或 RequireAuthorization()
方法来保护接口端点.
app.MapGet("/api/attribute-protected", [Authorize] () => "该端点使用 Authorize 特性来实现的保护");
app.MapGet("/api/method-protected", () => "该端点使用 RequireAuthorization 方法来实现的保护")
.RequireAuthorization();
直接将特性置于 Lambda 表示中是 C# 的一个新特性.
若此时通过 Swagger 请求会得到 401 的错误.
下面需要开始生成 JWT, 并对其校验, 并将 token 放在 Swagger 中.
生成 JWT bearer
逻辑上在登录的时候生成 JWT 票据. ASP.NET Core 提供了所有需要的 API.
首先是创建需要的登录方法 login
app.MapPost("/api/auth/login", (LoginRequest request) => {
if (request.UserName == "marco" && request.Password == "P@$$w0rd") {
// 生成 JWT bearer ...
}
return Results.BadRequest();
});
这里使用了硬编码, 为了简洁. 实际开发不会如此, 可以借助 Identity 框架实现更为复杂的逻辑.
传统逻辑中, 登录失败返回 400 的错误. 登录成功后生成 JWT, 并返回给客户端.
var claims = new List<Claim>() {
new (ClaimTypes.Name, request.Username)
};
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("MySecurityString"));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var jwtSecurityToken = new JwtSecurityToken(
issuer: "https://www.packpub.com",
audience: "minimal api client",
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: credentials
);
var accessToken = new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken);
return Results.Ok(new { AccessToken = accessToken });
JWT bearer 的创建包含很多的概念, 但是上述代码仅设计最基础的内容. 该 bearer 包含需要验证的用户标识, 以及相关的描述信息. 这些属性被称为 声明 (Claim), 本例中包含仅有 username, 我们可以根据需求添加多个 claim.
然后作者描述了上述代码创建 JWT 对象, JWT 字符串的逻辑, 并解释了 JWT 的结构, 加密算法, 以及签名的作用 (防止篡改).
HMAC, Hash-Based Message Authentication Code
有关 HMAC 和 Sha256 的信息可以参考微软的文档.
这里没有逐字翻译作者的文字, 关于安全与加密的细节没有加以说明.
在本例中仅包含最少的, 且必须的属性:
- Issuer. 是一个字符串, 一般情况是一个 URL. 用于表示创建 token 的实体名字. 简单说即发布 token 的实体.
- Audience. 表示使用 Token 的对象. 简单的说就是使用 token 的实体.
- 一组 claim. 可以翻译为一组声明.
- token 的过期时间 (使用 UTC 格式).
- 签名凭证.
在本利中, 生成签名用的数据均是硬编码的, 实际开发中应该采用配置.
有关创建 token 的细节可以参考微软的文档.
作者然后在 Swagger 中演示了 login 请求. 将返回的 accessToken 拷贝出来, 放在 https://jwt.ms 中进行查看.
有一个比较重要的概念, JWT 荷载数据是没有加密的 (仅仅使用 base64 做了字符串化). 也既是说任何人都可以读取到其中的数据. 而最后携带的签名串用于校验数据是否被篡改.
因此重要的是不要在 Token 中插入敏感数据. claim 中仅包含用户名, 角色, 以及 ID 等就可以了.
但是请记住, 这个 JWT 是足够安全的. 下一步是将 JWT 放到请求中, 并告诉 API 如何校验它.
验证 JWT bearer
创建 JWT bearer 后, 需要将其交给每一个 HTTP 请求的 Authorization Header 中, 这样 ASP.NET Core 才可以校验, 来保护我们的端点.
因此我们需要完善 JwtBearer()
方法. 之前描述该方法的目的是解析 Jwt 格式的数据.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationSchema)
.AddJwtBearer(opts => {
opts.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("MySecurityString")),
ValidIssuer = "https://www.packtpub.com",
ValidAudience = "Minimal APIs Client"
};
});
然后作者对上述代码做了解释. 其中重点是:
- 用于生成 Token 的秘钥必须是一致的
- Issuer 必须是一致的
- Audience 必须是一致的
否则校验是不通过的.
这里没有对过期时间进行配置, 这是默认的. 并且由于客户端与服务器存在时间上的差异, 默认有 5 分钟是延迟时间.
即既是 expire 到了过期的时间, 还有 5 分钟可以使用. 可以在 TokenValidateParameters
中使用 ClokcSkew
来设置.
剩下的事情就是让 Swagger 可以使用 这些 Token 了.
让 Swagger 支持 JWT
修改 AddSwaggerGen()
方法.
builder.Services.AddSwaggerGen(ops => {
opts.AddSecurityDefinition(JwtBearerDefaults.AuthenticationScheme, new OpenApiSecurityScheme {
Type = SecuritySchemaType.ApiKey,
In = ParameterLocation.Header,
Name = HeaderNames.Authorization,
Description = "插入 Token 时带上 Bearer 前缀"
});
opts.AddSecurityRequirement(new OpenApiSecurityRequirement {
{
new OpenApiSecurityScheme {
Reference = new OpenApiReference {
Type = ReferenceType.SecurityScheme,
Id = JwtBearerDefaults.AuthenticationScheme
}
},
Array.Empty<string>()
}
});
});
上述代码中, 使用 AddSecurityDefinition()
方法描述了我们如何保护 API. 我们使用 Api Key, 它是一个 bearer token. 并且置于 Authentication 请求头中.
然后通过 AddSecurityRequirement()
方法, 告知每一个端点都需要保护. 也就是说每一个请求都需要提供 token.
再次运行 Swagger 后就可以看到右上角有一个带锁的按钮, 用于输入 Token. 并且每一个接口右侧都有一个带锁的按钮.
测试鉴权
testing authentication
实际上就是利用 Swagger 完成各个接口的测试. 略.
尝试修改 token 某个字符后再测试, 也会获得 401 的响应.
这里已经定义了, 只有经过身份校验的才可以访问端点. 但是有一个常规需求, 在端点处理程序中需要访问用户信息.
ch2 中介绍了一个特殊的绑定, 可以使用 ClaimsPrincipal
对象来表示已登录的用户.
app.MapGet("/api/me", [Authorize] (ClaimsPrincipal user) => $"Logged username: {user.Identity?.Name}");
路由处理参数 user
会自动填充用户信息. 本例中, 我们只获得了 Name
, 它直接来源于 Claim
. 但它依旧可以包含其他信息以供使用. 细节可以参考微软文档.
实际上
Claims
是一个键值对, 可以包含任意的信息.
身份验证的部分到此为止, 下面来看看授权.
处理授权, 角色与策略
继鉴权之后, 就是授权阶段了. 这一阶段给鉴权的用户提供授权, 允许其干什么. Minimal API 项目与基于控制的项目, 采用相同的处理办法, 即角色或策略.
当标识被创建之时, 伴随着会有一个或多个角色. 例如一个用户可以是 Administrator
的角色, 另一个用户可以含有两个角色, 如: User
和 Stakeholder
等.
通常, 一个用户的能力, 由其角色来确定. 即角色决定了它可以做什么. 而角色仅仅是一个 Claim
. 它会在 JWT bearer 中体现.
接下来我们会看到, ASP.NET Core 内置了方法, 用于判断某个用户是否属于某个角色.
基于角色可以完成大部分的权限控制, 但是对更加复杂的权限控制, 我们可以自定义策略来实现.
接下来讨论, 基于角色的访问控制, 以及基于策略的访问控制. 这样, 可以限制, 只有复合特定角色, 或特定 Claim
, 亦或复合特定逻辑算法的用户才可以访问端点.
处理基于角色的授权
角色就是一种 Claim
, 因此在鉴权的时候, 将角色信息写入到 JWT 中即可.
app.MapPost("/api/auth/login", (LoginRequest request) => {
if (...) {
var claims = new List<Claim>() {
new Claim(ClaimTypes.Name, require.Username),
new Claim(ClaimTypes.Role, "Administrator"),
new Claim(ClaimTypes.Role, "User")
};
// ...
}
})
本例中使用硬编码, 实际开发中一般会结合诸如 Identity 的框架来实现.
然后重新运行代码, 将生成的 Token 置于 https://jwt.ms 中就可以看到其中含有的 claim 数据.
要应用该角色来限制用户访问端点, 可以在 [Authorize]
中使用 Role 参数, 或使用 RequireAuthorization()
方法.
app.MapGet("/api/admin-attribute-protected", [Authorize(Roles = "Administrator")] () => { });
app.MapGet("/api/admin-method-protected", () => { }).RequireAuthorization(new AuthorizeAttribute { Roles =
"Administrator" });
这样只有具有 Administrator
角色的用户才可以访问该端点.
注意 角色名是大小写敏感的.
那么针对下面端点:
app.MapGet("/api/stakeholder-protected", [Authorize(Roles = "Stakeholder")] () => {});
该端点只允许具有 Stakeholder
觉得的已登录的用户才可以访问. 而现有代码中并未设置该角色, 因此使用现有代码登录后访问不会得到 401 的错误, 而是获得 403 的错误.
- 401 是 Unauthorized
- 403 是 Forbidden
这一处理是会限制端点访问的. 也就是说, 不满足要求都无法进入到该端点.
但优势我们不需要限制端点, 而是根据用户的角色来不同对待. 那么可以在处理方法内使用 IsInRole
的方法进行判断.
app.MapGet("/api/auth/role-check", [Authorize] (ClaimsPrincipal user) => {
if (user.IsInRole("Administractor")) {
return "用户是 Administrator";
}
return "用户是普通用户";
});
这段代码中 [Authorize]
仅仅校验是否登录.
如果角色的方法无法满足我们对权限控制的需要, 就需要使用基于策略的方式来进行授权了.
应用基于策略的授权
策略是更为通用的授权规则. 基于角色的授权可以看成是特殊的基于策略的首选方案.
在处理更加复杂的授权情况时, 可以考虑使用基于策略的方法.
这个方法有两个步骤:
- 定义策略规则集合
- 在端点上作用具体的策略
策略是在 AddAuthorization()
方法中添加的. 每一个策略都有唯一的名字, 后面会引用该名字.
在角色不够的时候, 我们可以使用策略. 假如还需要携带用户的租赁 ID 时.
var claims = new List<Claim>() {
// ...
new ("tenant-id", "42")
};
同样, 在现实开发中不会硬编码, 这些数据会来自于数据库. 假若我们仅需要租户 ID 符合要求的到达端点, 这是一个自定义 claim
, ASP.NET Core 并不知道该如何处理授权. 因此我们需要自定义一个策略规则:
builder.Services.AddAuthorization(options => {
options.AddPolicy("Tenant42", policy => {
policy.RequireClaim("tenant-id", "42");
});
});
作者简要介绍了一下代码, 并说明 policy
是 AuthorizationPolicyBuilder
的实例.
我们可以在这里定义策略需要满足的内容, 包括用户, 角色, 声明等.
我们也可以在一个策略中约定满足多个声明 (claim
). 例如:
policy.RequireRole("Administrator").RequireClaim("tentant-id");
该方法的详细用法可以参考文档.
然后, 在我们需要保护的方法中, 我们需要指定策略名, 利用 Authorize
特性或 RequireAuthorization()
方法.
app.MapGet("/api/policy-attribute-protected", [Authorize(Policy = "Tentant42")] () => {});
app.MapGet("/api/policy-method-protected", () => {}).RequireAuthorization("Tentant42");
请求该端点时, 若租客 ID 不是 42, 或没有 "tentant-id" 的声明, 就会返回 403 Forbidden 的响应.
有一种情况, 即使定义了策略与角色还是不够. 例如更加复杂的校验, 或者基于动态参数的授权.
这种情况下, 我们就需要一个被称为 策略需求的 技术了. 它包含一系列授权规则, 我们可以在其中定义自己的验证逻辑.
要实现该功能, 我们需要两个对象
- 一个需求类 (requirement class), 该类实现
IAuthorizationRequirement
接口, 并且定义我们需要的需求. - 一个处理类 (handler class), 该类派生自
AuthorizationHandler
类, 并且包含验证需求的逻辑.
假定我们不希望非 Administrator 的用户在维护窗口期间访问端点. 由于该约束涉及到当前时间, 因此无法使用静态的约束来实现授权.
因此我们首先创建一个自定义的要求:
public class MaintenanceTimeRequirement: IAuthorizationRequirement {
public TimeOnly StartTime { get; init; }
public TimeOnly EndTime { get; init; }
}
该需求定义了维护窗口期的开始与结束时间. 在该时间段, 仅允许 Administrator
可以访问.
TimeOnly
是 C# 10 引入的新类型, 它仅仅存储一天中的时间, 而不是date
.细节可以参考文档.
请注意, IAuthorizationRequirement
接口只是一个占位符, 它未提供任何一个属性与方法. 它仅仅用于表示该类是必须的.
换句话说, 如果我们不需要任何额外的数据, 我们在实现该接口的时候, 可以不添加任何属性与方法.
需求必须被强制实现, 因此需要实现相关的处理程序:
public class MaintenanceTimeAuthorizationHandler: AuthorizationHandler<MaintenanceTimeRequirement> {
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MaintenanceTimeRequirement requirement) {
var isAuthorized = true;
if (!context.User.IsInRole("Administrator")) {
var time = OnlyTime.FromDateTime(DateTime.Now);
if (time >= requirement.StartTime && time <= requirement.EndTime) {
isAuthorized = false;
}
}
if (isAuthorized) {
context.Successed(requirement);
}
return Task.CompletedTask;
}
}
这个类派生自 AuthorizationHandler<MaintenanceTimeRequirement>
, 因此我们需要重写其 HandleRequireAsync
方法来实现校验.
该方法中我们使用了 AuthorizationHandlerContext
参数, 该参数可以访问到用于的信息.
如前面约束中的需求, 首先检查是否为 Administrator
, 如果不是, 在判断时间是否在维护窗口期. 如果是则不允许访问端点.
代码的最后, 校验成功, 即 isAuthorized
为 true
, 我们调用上下文的 Successed()
方法, 并将 requirement
作为参数传入, 以便后续需要使用该数据.
否则, 即无权限访问的时候, 不调用任何方法. 则表示校验未通过.
我们还没有完成所有的逻辑, 我们还需要定义策略, 并在服务提供程序中注册我们的处理程序.
builder.Services.AddAuthorization(options => {
options.AddPolicy("TimedAccessPolicy", policy => {
policy.Requirements.Add(new MaintenanceTimeRequirement {
StartTime = new TimeOnly(0, 0, 0),
EndTime = new TimeOnly(4, 0, 0)
});
})
});
builder.Services.AddScoped<IAuthorizationHandler, MaintenanceTimeAuthorizationHandler>();
此后, 我们只需要将策略引用至我们需要保护的方法上即可:
app.MapGet("/api/custom-policy-protected", [Authorize(Policy = "TimedAccessPolicy")] () => {});
然后进行测试, 不符合要求的会返回 403 Forbidden.
我们已经展示了策略的强大, 不仅如此, 我们还可以使用它定义全局的默认策略, 它会作用于所有的端点.
使用默认策略和备用策略
默认与备用策略在我们需要自动应用时非常有用. 实际上, 我们所使用的, 无参数 Authorize
特性, 以及无参数 RequireAuthorization()
方法就是 ASP.NET Core 内置的默认策略. 它约定必须要完成登录校验.
如果我们需要不同的默认条件, 我们只需要重定义 DefaultPolicy
属性即可. 方法写在 AddAuthorization
方法中.
builder.Services.AddAuthorization(options => {
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireClaim("tenant-id").Build();
options.DefaultPolicy = policy;
});
注意这个定义含义是, 默认校验已经鉴权的用户, 用户声明中含有 tenant-id
.
猜测每一个条件都对应一个方法. 待验证.
备用策略是用在默认的所有端点上. 即不用添加 [Authorize]
这类特性就具有的.
builder.Services.AddAuthorization(options => {
options.FallbackPolicy = options.Defaultpolicy;
});
这就表示所有的接口都需要满足鉴权后才可以使用.
如果使用了这个方法, 但是极个别方法需要被公开. 那么可以使用 [AllowAnonymous]
特性或 AllowAnonymout()
方法.
要深入的了解基于策略的授权方案, 可以参考官网文档.
小结
理解鉴权与授权, 是最基本的要求. 使用 JWT bearer 来鉴权角色与策略. 我们可以定义更为复杂的授权规则. 也可以结合使用标准与自定义的规则.
本章仅仅简要介绍了鉴权与授权的处理. 更为实用的是结合 Identity 框架类实现. 细节可以参考 Identity 文档.
下一章讨论多语言支持, 以及不同的时间格式, 时区等.