通过OpenIddict设计一个授权服务器03-客户凭证流程

在本部分中,我们将把 OpenIddict 添加到项目中,并实施第一个授权流程:客户端凭证流。

添加 OpenIddict 软件包

首先,我们需要安装 OpenIddict NuGet 软件包

bash 复制代码
dotnet add package OpenIddict
dotnet add package OpenIddict.AspNetCore
dotnet add package OpenIddict.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.InMemory

除了主库,我们还安装了 OpenIddict.AspNetCore 软件包,该软件包可将 OpenIddict 集成到 ASPNET Core 主机中。

OpenIddict.EntityFrameworkCore 包支持 Entity Framework Core。现在我们将使用内存实现,为此我们使用了 Microsoft.EntityFrameworkCore.InMemory. 包。

设置 OpenIddict

我们将首先介绍启动和运行 OpenIddict 所需的最低条件。必须启用至少一个 OAuth 2.0/OpenID Connect 流程。我们选择启用客户端凭证流,它适用于机器到机器应用程序。在本系列的下一部分,我们将使用 PKCE 实现授权代码流,这是单页应用程序 (SPA) 和本地/移动应用程序的推荐流程。

开始对 Startup.cs 进行以下更改:

cs 复制代码
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
       .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
       {
           options.LoginPath = "/account/login";
       });
builder.Services.AddDbContext<DbContext>(options =>
{
    // 使用内存存储
    options.UseInMemoryDatabase(nameof(DbContext));

    // 注册OpenIddict所需的实体集。
    options.UseOpenIddict();
});
builder.Services.AddOpenIddict()
        // 注册 OpenIddict 核心组件
        .AddCore(options =>
        {
            // 配置 OpenIddict 以使用 EF Core 存储器/模型
            options.UseEntityFrameworkCore()
                .UseDbContext<DbContext>();
        })
        // 注册 OpenIddict 服务器组件
        .AddServer(options =>
        {
            options
                .AllowClientCredentialsFlow();

            options
                .SetTokenEndpointUris("/connect/token");

            //令牌的加密和签名
            options
                .AddEphemeralEncryptionKey()
                .AddEphemeralSigningKey();

            //注册范围(权限)
            options.RegisterScopes("api");

            //注册 ASP.NET Core 主机并配置 ASP.NET Core 特定选项 
            options
                .UseAspNetCore()
                .EnableTokenEndpointPassthrough();
        });

var app = builder.Build();

app.UseStaticFiles();

app.UseRouting();
app.UseAuthentication();
app.MapDefaultControllerRoute();
app.Run();

首先,在 ConfigureServices 方法中注册 DbContext。OpenIddict 原生支持 Entity Framework Core、Entity Framework 6 和 MongoDB,你也可以提供自己的存储。

在本例中,我们将使用 Entity Framework Core,并使用内存数据库。options.UseOpenIdDict 调用会注册 OpenIddict 所需的实体集。

接下来是注册 OpenIddict 本身。AddOpenIddict() 调用会注册 OpenIddict 服务,并返回一个 OpenIddictBuilder 类,我们可以用它来配置 OpenIddict。

首先注册的是核心组件。OpenIddict 被指示使用 Entity Framework Core,并使用前面提到的 DbContext。

接下来,注册服务器组件并启用客户端凭证流。为使该流程正常运行,我们需要注册一个令牌端点。我们需要自己实现这个端点。我们稍后再做这项工作。

要使 OpenIddict 能够加密和签名令牌,我们需要注册两个密钥,一个用于加密,一个用于签名。在本例中,我们将使用短暂密钥。短暂密钥会在应用程序关闭时自动丢弃,因此使用这些密钥签名或加密的有效负载会自动失效。这种方法只能在开发过程中使用。在生产过程中,建议使用 X.509 证书。

RegisterScopes 定义了支持哪些作用域(权限)。在本例中,我们只有一个名为 api 的作用域,但授权服务器可以支持多个作用域

UseAspNetCore() 调用用于将 AspNetCore 设置为 OpenIddict 的主机。我们还调用了 EnableTokenEndpointPassthrough,否则会阻止对未来令牌端点的请求。

要检查 OpenIddict 是否配置正确,我们可以启动应用程序并导航到:https://localhost:5001/.well-known/openid-configuration,得到的回应应该是这样的:

json 复制代码
{
  "issuer": "https://localhost:5001/",
  "token_endpoint": "https://localhost:5001/connect/token",
  "jwks_uri": "https://localhost:5001/.well-known/jwks",
  "grant_types_supported": [
    "client_credentials"
  ],
  "scopes_supported": [
    "openid",
    "api"
  ],
  "claims_supported": [
    "aud",
    "exp",
    "iat",
    "iss",
    "sub"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "subject_types_supported": [
    "public"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_post",
    "private_key_jwt",
    "client_secret_basic"
  ],
  "claims_parameter_supported": false,
  "request_parameter_supported": false,
  "request_uri_parameter_supported": false,
  "authorization_response_iss_parameter_supported": true
}

在本指南中,我们将使用 Postman 测试授权服务器,但也可以使用其他工具。

下面是一个使用 Postman 的授权请求示例。授权类型是客户凭据流。我们指定了访问令牌 url、客户端 ID 和秘密,以验证客户端身份。我们还请求访问 api 范围。

直接请求https://localhost:5001/connect/token

如果我们请求令牌,操作将失败:client_id 无效。这是有道理的,因为我们还没有在授权服务器上注册任何客户端。

发送post请求

用代码实现

cs 复制代码
using Flurl.Http;

namespace AuthClient
{
    internal class Program
    {
        static void Main(string[] args)
        {
            var dic = new Dictionary<string, string>();
            dic.Add("grant_type", "client_credentials");
            dic.Add("client_id","postman");
            dic.Add("client_secret", "postman-secret");
            dic.Add("scope", "api");
            string url = "https://localhost:5001/connect/token";
            var response = url.PostUrlEncodedAsync(dic).Result;
            Console.WriteLine(response.ResponseMessage.Content.ReadAsStringAsync().Result);
            Console.WriteLine("完成");
        }
    }
}

我们可以通过将客户端添加到数据库来创建客户端。为此,我们创建了一个名为 TestData 的类。测试数据实现了 IHostedService 接口,这使我们能够在应用程序启动时在 Startup.cs 中执行生成测试数据的操作。

cs 复制代码
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions;

namespace AuthorizationServer
{
    public class TestData : IHostedService
    {
        private readonly IServiceProvider _serviceProvider;

        public TestData(IServiceProvider serviceProvider)
        {
            _serviceProvider = serviceProvider;
        }

        public async Task StartAsync(CancellationToken cancellationToken)
        {
            using var scope = _serviceProvider.CreateScope();

            var context = scope.ServiceProvider.GetRequiredService<DbContext>();
            await context.Database.EnsureCreatedAsync(cancellationToken);

            var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();

            if (await manager.FindByClientIdAsync("postman", cancellationToken) is null)
            {
                await manager.CreateAsync(new OpenIddictApplicationDescriptor
                {
                    ClientId = "postman",
                    ClientSecret = "postman-secret",
                    DisplayName = "Postman",
                    Permissions =
                    {
                        OpenIddictConstants.Permissions.Endpoints.Token,
                        OpenIddictConstants.Permissions.GrantTypes.ClientCredentials,
                        OpenIddictConstants.Permissions.Prefixes.Scope + "api"
                    }
                }, cancellationToken);
            }
        }

        public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
    }
}

客户端在测试数据中注册。客户端 ID 和秘密用于客户端与授权服务器之间的身份验证。权限决定了客户端的选项。

在这种情况下,我们允许客户端使用客户端凭据流,访问令牌端点,并允许客户端请求 api 范围。

在 Startup.cs 中注册测试数据服务,以便在应用程序启动时执行:

cs 复制代码
builder.Services.AddHostedService<TestData>();

如果我们再次尝试用 Postman 获取访问令牌,请求仍然会失败。这是因为我们还没有创建令牌端点。我们现在就创建。

创建一个名为 AuthorizationController 的新控制器,我们将在此托管端点:

cs 复制代码
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System.Security.Claims;

namespace AuthorizationServer.Controllers
{
    
    public class AuthorizationController : Controller
    {
        [HttpPost("~/connect/token")]
        public IActionResult Exchange()
        {
            var request = HttpContext.GetOpenIddictServerRequest() ??
                          throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

            ClaimsPrincipal claimsPrincipal;

            if (request.IsClientCredentialsGrantType())
            {
                // 注意:OpenIddict 会自动验证客户端凭证:
                // 如果 client_id 或 client_secret 无效,则不会调用此操作。
                var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

                // Subject (sub)是必填字段,我们在此使用客户 ID 作为主题标识符。
                identity.AddClaim(OpenIddictConstants.Claims.Subject, request.ClientId ?? throw new InvalidOperationException());

                // 添加一些要求,别忘了添加目的地,否则它不会被添加到访问令牌中。
                identity.AddClaim("some-claim", "some-value", OpenIddictConstants.Destinations.AccessToken);
                //上面这句话不起作用,下面的可以
                identity.AddClaim(new Claim("some-claim2", "some-value2").SetDestinations(OpenIddictConstants.Destinations.AccessToken));
                claimsPrincipal = new ClaimsPrincipal(identity);

                claimsPrincipal.SetScopes(request.GetScopes());
            }
            else
            {
                throw new InvalidOperationException("The specified grant type is not supported.");
            }

            // 返回 SignInResult 结果时,OpenIddict 将向用户发放相应的访问/身份令牌。
            return SignIn(claimsPrincipal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
        }
    }
}

其中一个操作是 "Exchange"。所有流程(不仅是客户证书流程)都使用该操作来获取访问令牌。

在客户凭据流中,令牌是根据客户凭据签发的。而在授权码流中,使用的是同一个端点,但随后会用授权码来交换令牌。我们将在第四部分看到这一点。

目前,我们需要重点关注客户端凭证流程。当请求进入 Exchange 操作时,客户端凭证(ClientId 和 ClientSecret)已经通过 OpenIddict 验证。因此,我们不需要对请求进行验证,只需创建一个声称委托人并使用该委托人登录即可。

声明主体与账户控制器中使用的声明不同,后者基于 Cookie 身份验证处理程序,仅在授权服务器本身的上下文中使用,以确定用户是否已通过身份验证。

我们必须创建基于 OpenIddictServerAspNetCoreDefaults.AuthenticationScheme 的请求声明。这样,当我们在该方法末尾调用 SignIn 时,OpenIddict 中间件就会处理登录并返回一个访问令牌作为对客户端的响应。

只有当我们指定目的地时,才会在访问令牌中加入权利要求声明中定义的权利要求。示例中的 "some-value "请求将被添加到访问令牌中。

主题(Subject)要求是必填项,您无需指定目的地,因为它将包含在访问令牌中。

我们还通过调用 claimsPrincipal.SetScopes(request.GetScopes()); 授权所有请求的作用域。OpenIddict 已经检查了所请求的作用域是否被允许(一般情况下和针对当前客户端)。我们之所以要在此处手动添加作用域,是因为我们可以根据需要过滤此处授予的作用域。

一枚令牌定乾坤

让我们再次尝试用 Postman 获取访问令牌,这次应该能成功。

从 OpenIddict v3 开始,访问令牌默认采用 Jason Web Token(JWT)格式。这使我们能够使用 jwt.io 检查令牌(感谢 Auth0 提供的服务!)。

一个问题是,令牌不仅要签名,还要加密。OpenIddict 默认会对访问令牌进行加密。我们可以在 Startup.cs 中配置 OpenIddict 时禁用这种加密。

现在,当我们重启授权服务器并请求一个新的令牌时。将令牌粘贴到 jwt.io 并查看令牌内容:

可以看到,客户 ID postman 被设置为Subject(sub)。此外,访问令牌中还添加了 some-claim claim。

接下来干什么

恭喜您,您已经使用 OpenIddict 实现了客户端凭证流!

您可能已经注意到,客户端凭证流未使用登录页面。该流程会立即将客户端凭据交换为令牌,适用于机器对机器应用程序。

接下来,我们将使用 PKCE 实现授权代码流,这是单页应用程序(SPA)和移动应用程序的推荐流程。该流程将涉及用户,因此我们的登录页面将发挥作用。

相关推荐
运维老司机2 分钟前
Jenkins修改LOGO
运维·自动化·jenkins
D-海漠18 分钟前
基础自动化系统的特点
运维·自动化
我言秋日胜春朝★26 分钟前
【Linux】进程地址空间
linux·运维·服务器
繁依Fanyi1 小时前
简易安卓句分器实现
java·服务器·开发语言·算法·eclipse
C-cat.1 小时前
Linux|环境变量
linux·运维·服务器
yunfanleo1 小时前
docker run m3e 配置网络,自动重启,GPU等 配置渠道要点
linux·运维·docker
m51271 小时前
LinuxC语言
java·服务器·前端
运维-大白同学1 小时前
将django+vue项目发布部署到服务器
服务器·vue.js·django
烦躁的大鼻嘎2 小时前
【Linux】深入理解GCC/G++编译流程及库文件管理
linux·运维·服务器
乐大师2 小时前
Deepin登录后提示“解锁登陆密钥环里的密码不匹配”
运维·服务器