使用 IdentityServer4 完成 OAuth2.0 的 access_token 验证

在 ASP.NET Core 中,用于实现 OAuth 认证与单点登录的框架并不是很多,大体上有 IdentityServer 和 OpenIddict 以及官方的 Microsoft.AspNetCore.Authorization。在某些情况下,我们并不想去做一个 OAuth 的客户端,而是要做一个服务端的实现,而这就比较复杂了,本文将覆盖对 Bearer Token 验证的几个坑。

前置内容

很多框架的文档以及博客中,都只是对 OAuth2.0 这个协议有一个抽象概念的解析,但是在 OAuth2.0 的具体实现中,并不是如同很多流程图中所描述的 “Client 向 RS 请求资源时,RS 会验证 Client 发来的经 RO 授权的由 AuthZ 颁发的 access_token” 一般简单。这个验证过程,抛去如何获得 access_token 的过程不谈,就算把 access_token 连同请求一起提交给 RS 就有至少三种途径。当然,最终选择的方式肯定是由 RS 确定,但是在验证 token 这个过程中,有很多非常琐碎的细节值得注意。

这里又会牵扯一个新的概念,叫做 JWT (JSON Web Token),在 RFC 7519 中提出。一个 JWT 由头部载荷签名组成,我们无需关心 JWT 的实际组织结构,只需要知道 JWT 代表了一个用户和服务器间已经建立的认证关系即可,并且 JWT 最后附上了一段签名来确保 JWT 的可靠性。

那么,JWT 显然是一段 JSON,这个 JSON 是如何传递给 RS 的呢?是用 URL Encoder 吗?
显然不是,但是 OAuth2.0 协议 (RFC 6749) 中并没有对如何传递 Token 做出规定,因此还有另一个协定 (RFC 6750) 来指导如何传递 JWT。在 RFC 6750 中,用 Bearer Token 这一术语来描述传递与验证 Token 的方法。但是需要注意的是,RFC 6750 并不属于 OAuth2.0 协议,而仅是一个指导性协定,最终的产品可以选择使用 Bearer 来作为具体实现,也可以确立一套自成体系的协议。

添加验证

在 RFC 6750 中,推荐使用 HTTP Header 与 Request Body 方式来传递 Bearer,对于客户端的实现在此不便累述,以下均假设使用 HTTP Header 方式,也就是说 Client 的请求头中包含有

1
2
3
GET /resource HTTP/1.1
Host: server.example.com
Authorization: Bearer <token>

对于 IdentityServer4 的默认配置,只有默认的 IdentityServer4.Validation.JwtBearerClientAssertionSecretParser 来对 Request Body 进行 Bearer Token 的验证。如果想要加入 HTTP Header 方式的验证,需要安装一个包叫 IdentityServer4.AccessTokenValidation,这个包在内部配置了 Microsoft.AspNetCore.Authentication 并使用 Microsoft.AspNetCore.Authentication.JwtBearer 来对通过 HTTP Header 传递进来的 Bearer 进行验证,这一切只需要添加一行即可:

1
services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme).AddIdentityServerAuthentication();

是不是感觉非常易用?梦里啥都有。
然而真正运行之后,Microsoft.AspNetCore.Authentication 将会抛出一个为 IDX10500: Signature validation failed. No security keys were provided to validate the signature. 的异常,因为没有配置证书,无法对 JWT 的签名进行验证。

什么?我不是能生成 JWT 吗,怎么会没有证书去验证呢?
在很多情况下,AuthN 和 AuthZ 并不是同一台服务器,生成 JWT 只是说明 AuthN 拥有了证书,而 AuthZ 需要去验证 AuthN 的签名,并且无法与 AuthN 直接通信。在我们这种全部服务器都跑在一个应用中的场景,仍然需要与分布式一样配置,也就是为 AuthZ 添加一个证书。

我希望的是 IdentityServer 能为包 IdentityServer4.AccessTokenValidation 内置添加证书的功能,这也更加符合直觉,既然 IdentityServer4.AccessTokenValidation 已经能验证 JWT 了,为什么我还要去使用更加原生 `Microsoft.AspNetCore.Authorization 来单独添加一个证书呢?

在 IdentityServer4.AccessTokenValidation 的源码中的 IdentityServerAuthenticationOptions.cs 文件的 void ConfigureJwtBearer(JwtBearerOptions jwtOptions) 函数中,我们来硬编码添加一个证书:

1
2
3
4
5
6
7
8
9
10
11
12
13
internal void ConfigureJwtBearer(JwtBearerOptions jwtOptions)
{
/* some codes */

var filename = Path.Combine(Directory.GetCurrentDirectory(), "tempkey.rsa");
var keyFile = File.ReadAllText(filename);
var tempKey = JsonConvert.DeserializeObject<TemporaryRsaKey>(keyFile);
var rsa = new RsaSecurityKey(tempKey.Parameters) {KeyId = tempKey.KeyId};
jwtOptions.TokenValidationParameters.IssuerSigningKey = rsa;
jwtOptions.TokenValidationParameters.ValidIssuer = "http://localhost:5000";

/* some codes */
}

这里硬编码的 tempkey.rsa 是 IdentityServer4 默认的开发环境证书,也就是使用 services.AddDeveloperSigningCredential() 创建的证书,而下面读取证书的方法也和这个扩展方法中的内容一样。如果需要设置一个开发环境证书的话,记得为 IdentityServerAuthenticationOptions 新增一个属性来传递证书。
ValidIssuer 的值是生成 JWT 的服务器地址,既然我们是单应用程序,那就和应用域名一样了。

再次运行,可以看到结果

1
2
3
4
5
6
7
8
[22:12:40 Information] Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler
Successfully validated the token.

[22:12:40 Information] Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler
AuthenticationScheme: BearerIdentityServerAuthenticationJwt was successfully authenticated.

[22:12:40 Information] IdentityServer4.AccessTokenValidation.IdentityServerAuthenticationHandler
AuthenticationScheme: Bearer was successfully authenticated.

说明 access_token 已成功验证。