使用 OpenIddict 建立一個支援 Client Credentials Grant 的 Authentication Server 來保護你的 Web API - Part 1

本篇內容主要講解如何使用 OpenIddict 來建立一個支援 Client Credentials Grant 的 Authentication Server 來保護你的 Web API。

內容將會分成兩個部分,第一部分包含方案說明,以及建立 Authentication Server 專案,第二部分則會建立一個 Resource Server (Web API) 專案,及一個 Console App 作為 Client。

方案說明

我們這裡會建立一個方案,裡面包含了 Auth Server, Resource Server 及 Client App,作法如下:

  1. 建立一個 Auth Server
    • Auth Server 需支援 client credentials grant
    • Auth Server 需要定義一個 client
      • client 可以使用 client credentials grant
    • Auth Server 需要有一個 introspection endpoint,供 Resource Server 呼叫,作為驗證 access token 用
  2. 建立一個 Resource Server (Web API)
    • 包含一個 endpoint - WeatherForecast (Web API 專案的內建範例)
    • 需要 Auth Server 發出的 access token 才能存取
  3. 建立一個 Console App 做為 Client
    • Client App 會向 Auth Server 取得 access token
    • Client App 會呼叫 Resource Server 取得資料

Auth Server

1. 開新專案

首先,先開一個新的 ASP.NET Core MVC 專案,專案名稱為 AuthServer,Authentication Type 選擇 None。

Create a new project Configure your new project Additional information

2. 安裝 OpenIddict 套件

這個步驟需要安裝四個 NuGet 套件,分別為:

  1. Microsoft.EntityFrameworkCore.SqlServer 5.0.8
  2. Microsoft.EntityFrameworkCore.Tools 5.0.8
  3. OpenIddict.AspNetCore 3.1.0
  4. OpenIddict.EntityFrameworkCore 3.1.0

版本編號為撰文當下的最新版本

NuGet package 安裝方式相信大家都很熟悉了,就不另外講解

3. 建立 DbContext

在專案底下建立一個名為 Data 的資料夾,在資料夾中建立一個 AppDbContext.cs,內容如下:

using Microsoft.EntityFrameworkCore;

namespace AuthServer.Data
{
    public class AppDbContext : DbContext
    {
        public AppDbContext(DbContextOptions<AppDbContext> options)
            : base(options)
        {

        }
    }
}

4. 加入連線字串

開啟 appsettings.json ,加入 SQL 連線字串,這裡用 LocalDB 為範例。

{
    "ConnectionStrings": {
        "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=AuthServer;Trusted_Connection=True;MultipleActiveResultSets=true"
    },
    // ...
}

5. 修改 Startup.cs

這一步,我們要在 Startup.cs 中加入 DbContext 以及 OpenIddict 的相關設定。

開啟 Startup.cs 並做以下修改。

using AuthServer.Data;
using Microsoft.EntityFrameworkCore;
public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<AppDbContext>(options =>
    {
        options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"));
        options.UseOpenIddict();
    });

    //OpenIddict
    services.AddOpenIddict()
        .AddCore(options =>
        {
            options.UseEntityFrameworkCore()
                .UseDbContext<AppDbContext>();
        })
        .AddServer(options =>
        {
            // 允許 auth server 支援 client credentials grant
            options.AllowClientCredentialsFlow();

            options
                // 設定取得 access token 的 endpoint
                .SetTokenEndpointUris("/connect/token")
                // 設定 introspection endpoint
                .SetIntrospectionEndpointUris("/connect/introspect")
                ;

            options
                // 產生開發用的加密金鑰,production 建議用存在本機的 X.509 certificates
                .AddEphemeralEncryptionKey()
                .AddEphemeralSigningKey()
                // 停用 access token 加密,production 不建議使用
                .DisableAccessTokenEncryption()
                ;

            options
                .UseAspNetCore()
                .EnableTokenEndpointPassthrough()
                ;

        });

    services.AddControllersWithViews();
}

接著在 Configure 中,app.UseRouting() 後面加入下面程式片段

app.UseAuthentication();

6. EF Migration

點選 Tools > NuGet Package Manager > Package Manager Console,接著在 Package Manager Console 中輸入以下指令:

Add-Migration AddOpenIddictTables
Update-Database

完成之後,可以使用 SSMS 開啟資料庫,應該可以看到已經建立好四個 OpenIddict 的資料表。

OpenIddict tables

7. 加入 Token Endpoint

在 Startup.cs 中,我們有設定一個取得 access token 的 endpoint,現在要將它實作出來。

在 Controllers 資料夾中,新增 AuthorizationController.cs,程式碼如下:

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using static OpenIddict.Abstractions.OpenIddictConstants;

namespace AuthServer.Controllers
{
    public class AuthorizationController : Controller
    {
        private readonly IOpenIddictApplicationManager _applicationManager;

        public AuthorizationController(
            IOpenIddictApplicationManager applicationManager
            )
        {
            _applicationManager = applicationManager;
        }

        [HttpPost("~/connect/token"), Produces("application/json")]
        public async Task<IActionResult> Exchange()
        {
            var request = HttpContext.GetOpenIddictServerRequest() ??
                throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

            if (request.IsClientCredentialsGrantType())
            {
                var application = await _applicationManager.FindByClientIdAsync(request.ClientId);
                if (application == null)
                {
                    throw new InvalidOperationException("The application details cannot be found in the database.");
                }

                var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);

                // 將 claim (sub=clientid) 加到 access token, id_token
                identity.AddClaim(Claims.Subject, await _applicationManager.GetClientIdAsync(application),
                    Destinations.AccessToken, Destinations.IdentityToken);

                // 將 claim (name=client display name) 加到 access token, id_token
                identity.AddClaim(Claims.Name, await _applicationManager.GetDisplayNameAsync(application),
                    Destinations.AccessToken, Destinations.IdentityToken);

                var principal = new ClaimsPrincipal(identity);
                
                return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
            }

            throw new InvalidOperationException("The specified grant type is not supported.");
        }
    }
}

8. 加入測試用的 Client 設定

由於我們並沒有後台可以管理 Client 應用程式,所以我們需要透過程式碼來加入相關的設定。

新增一個 TestClient.cs,並加入以下程式碼:

using AuthServer.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OpenIddict.Abstractions;
using System;
using System.Threading;
using System.Threading.Tasks;
using static OpenIddict.Abstractions.OpenIddictConstants;

namespace AuthServer
{
    public class TestClient : IHostedService
    {
        private readonly IServiceProvider _serviceProvider;

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

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

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

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

            if (await manager.FindByClientIdAsync("my-console-app", cancellationToken) is null)
            {
                await manager.CreateAsync(new OpenIddictApplicationDescriptor
                {
                    // Client Id
                    ClientId = "my-console-app",
                    // Client Secret
                    ClientSecret = "388D45FA-B36B-4988-BA59-B187D329C207",
                    DisplayName = "My Console App",
                    Permissions =
                    {
                        // 允許使用 token endpoint
                        Permissions.Endpoints.Token,
                        // 允許使用 client credentials flow                
                        Permissions.GrantTypes.ClientCredentials,
                    }
                }, cancellationToken);
            }

            //introspection client
            if (await manager.FindByClientIdAsync("resource_server") == null)
            {
                var descriptor = new OpenIddictApplicationDescriptor
                {
                    ClientId = "resource_server",
                    ClientSecret = "846B62D0-DEF9-4215-A99D-86E6B8DAB342",
                    DisplayName = "Resource Server Introspection",
                    Permissions =
                    {
                        Permissions.Endpoints.Introspection
                    }
                };

                await manager.CreateAsync(descriptor);
            }
        }

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

上面的程式中,我們一共建立了兩個 ClientId。

一個是 my-console-app,是給我們的 Client 使用,從 permission 設定中可以看到,這個 Client 允許使用 token endpoint 取得 access token,且允許使用的認證流程為 client credentials grant。

另一個則是 resource_server,是給的 resource server 驗證 access token 使用,所以它的 permission 就只有 introspection endpoint。當 client 拿著 access token 向 resource server 要求存取資料時,resource server 就會透過這組 clientid 來向 auth server 要求驗證 access token 的有效性。

再完成 TestClient.cs 之後,需要將其加入 Startup.cs,這樣在網站執行後,就可以自動在資料庫中建立 client 的設定。

請將下面程式片段加在 Startup.cs 中的 ConfigureServices 的最後。

services.AddHostedService<TestClient>();

完成之後,執行程式,檢查 OpenIddictApplications 這個資料表,應該可以看到兩個 Applications 的設定。

OpenIddictApplications

9. 測試 Authentication Server

目前我們已經完成 AuthServer,我們可以用 Rest Client for Visual Studio Code,做一下簡單的測試

Request

POST https://localhost:44337/connect/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=my-console-app
&client_secret=388D45FA-B36B-4988-BA59-B187D329C207

Response

{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IlRYWkc2RDc3SkU4NTNDVElTWEc5R0pPWUxaR0lGR....",
  "token_type": "Bearer",
  "expires_in": 3599
}

你可以將 access_token 複製下來,並利用 jwt.io,來觀察 access_token 所包含的資訊

Use jwt.io to inspect access token

結論

在這個部分,我們已經將 AuthServer 完成了,接下來第二部分,將會繼續完成 Resource Server (Web API) 及 Client 的 Console 應用程式。