Identity Server

在上一篇的文章 有提到要建立自己的 OIDC 伺服器,今天就來看看在 .NET 生態系中經常使用到的 Identity Server 這篇文章使用的是 Duende.IdentityServer 可以在測試及與開發時免費使用

dotnet new install Duende.Templates

將安裝下列範本套件:
   Duende.Templates

成功: Duende.Templates::1.2.1 已安裝下列範本:
範本名稱                                                    簡短名稱              語言  標記
----------------------------------------------------------  --------------------  ----  -------------------------
Duende BFF Host using a Remote API                          duende-bff-remoteapi  [C#]  Web/Duende/BFF
Duende BFF using a Local API                                duende-bff-localapi   [C#]  Web/Duende/BFF
Duende BFF with Blazor autorender                           duende-bff-blazor     [C#]  Web/Duende/BFF
Duende IdentityServer                                       duende-is             [C#]  Web/Duende/IdentityServer
Duende IdentityServer Empty                                 duende-is-empty       [C#]  Web/Duende/IdentityServer
Duende IdentityServer Quickstart UI (UI assets only)        duende-is-ui          [C#]  Web/IdentityServer
Duende IdentityServer Web Client                            duende-webclient      [C#]  Web/Duende/IdentityServer
Duende IdentityServer with ASP.NET Core Identity            duende-is-aspid       [C#]  Web/Duende/IdentityServer
Duende IdentityServer with Entity Framework Stores          duende-is-ef          [C#]  Web/Duende/IdentityServer
Duende IdentityServer with In-Memory Stores and Test Users  duende-is-inmem       [C#]  Web/Duende/IdentityServer

接下來我們就照著官方文檔來過一遍 Doc 先建立一個 Duende IdentityServer Empty 範本專案並添加到方案內,簡短名稱為 isempty

mkdir quickstart
cd quickstart
mkdir src
dotnet new sln -n Quickstart

cd src
dotnet new duende-is-empty -n IdentityServer

cd ..
dotnet sln add ./src/IdentityServer/IdentityServer.csproj

建立完成後打開方案來看看樣板自動建立了什麼東西,這邊列出幾項比較特別的

  • IdentityServer 專案安裝了 Duende.IdentityServer Nuget Package
  • AddIdentityServer 方法註冊 Services
  • UseIdentityServer 方法註冊 Middleware
  • 新增靜態類別 Config.cs 裡面有三個的 Property IdentityResources ApiScopes Clients 在設定 Application Builder 時讀取這些設定值

OIDC Discovery Documents 與伺服器自動設定

在開始寫程式之前先重新了解一下我們之前專案 IdentityDapper 的登入流程

  1. 呼叫後端 LoginGoogle 方法,透過 Challenge 搭配 ClientId、Scope(例如 openid、email)以及其他設定,生成 Google 授權網址。
  2. 瀏覽器跳轉到剛剛生成的 Google 授權網址。
  3. 使用者在 Google 授權頁面上同意存取權限。
  4. Google 授權伺服器回傳授權碼(Code),並將瀏覽器跳轉到後端指定的 Callback 網址。
  5. 後端使用這個 Code 向 Google Token 伺服器請求取得 Access Token 和 ID Token。
  6. 完成驗證後,瀏覽器轉跳至後端的 GoogleResponse 方法,處理登入結果。

微軟提供的 Microsoft.AspNetCore.Authentication.Google 是透過 GoogleOptions 事先設定授權、Token 與使用者資訊伺服器網址, 所以我們只需要設定 Google 的 ClientId 與 ClientSecret 剩下的背後會自行處理。 GoogleOptions GoogleDefaults

public class GoogleOptions : OAuthOptions
{
    public GoogleOptions()
    {
        CallbackPath = new PathString("/signin-google");
        AuthorizationEndpoint = GoogleDefaults.AuthorizationEndpoint;
        TokenEndpoint = GoogleDefaults.TokenEndpoint;
        UserInformationEndpoint = GoogleDefaults.UserInformationEndpoint;
        UsePkce = true;
        Scope.Add("openid");
        Scope.Add("profile");
        Scope.Add("email");

        ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id"); // v2
        ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "sub"); // v3
        ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
        ClaimActions.MapJsonKey(ClaimTypes.GivenName, "given_name");
        ClaimActions.MapJsonKey(ClaimTypes.Surname, "family_name");
        ClaimActions.MapJsonKey("urn:google:profile", "link");
        ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
    }
    
    public string? AccessType { get; set; }
}
    
public static class GoogleDefaults
{
    public const string AuthenticationScheme = "Google";
    public static readonly string DisplayName = "Google";
    public static readonly string AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
    public static readonly string TokenEndpoint = "https://oauth2.googleapis.com/token";
    public static readonly string UserInformationEndpoint = "https://www.googleapis.com/oauth2/v3/userinfo";
}

當我們要架設或整合一套授權伺服器(Identity Server)時,首要任務是取得該伺服器的配置資訊。 OpenID Foundation (OIDF) 制定了標準規範,要求授權伺服器必須提供一個存取資訊 Endpoint,稱為 Discovery Document。

根據規範,此預設路徑 Endpoint 固定為: /.well-known/openid-configuration 它扮演了「服務說明書」的角色,讓 Client 無需手動設定,就能自動得知如何與該授權伺服器進行溝通(包含授權網址、換取 Token 網址等)。

以 Google 為例,當我們存取 https://accounts.google.com/.well-known/openid-configuration 時,會獲得一份 JSON 格式的清單

{
 "issuer": "https://accounts.google.com",
 "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
 "device_authorization_endpoint": "https://oauth2.googleapis.com/device/code",
 "token_endpoint": "https://oauth2.googleapis.com/token",
 "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
 "revocation_endpoint": "https://oauth2.googleapis.com/revoke",
 "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
 "response_types_supported": [
  "code",
  "token",
  "id_token",
  "code token",
  "code id_token",
  "token id_token",
  "code token id_token",
  "none"
 ],
 "response_modes_supported": [
  "query",
  "fragment",
  "form_post"
 ],
 "subject_types_supported": [
  "public"
 ],
 "id_token_signing_alg_values_supported": [
  "RS256"
 ],
 "scopes_supported": [
  "openid",
  "email",
  "profile"
 ],
 "token_endpoint_auth_methods_supported": [
  "client_secret_post",
  "client_secret_basic"
 ],
 "claims_supported": [
  "aud",
  "email",
  "email_verified",
  "exp",
  "family_name",
  "given_name",
  "iat",
  "iss",
  "name",
  "picture",
  "sub"
 ],
 "code_challenge_methods_supported": [
  "plain",
  "S256"
 ],
 "grant_types_supported": [
  "authorization_code",
  "refresh_token",
  "urn:ietf:params:oauth:grant-type:device_code",
  "urn:ietf:params:oauth:grant-type:jwt-bearer"
 ]
}

所以就算是不同家授權伺服器只要有遵守標準規範,那麼授權端點都會用 authorization_endpoint 當作 key 值, 因此有了這個設定文檔系統就能自動化配置好所有路徑,就不需要在額外依賴 GoogleOptions 來紀錄這些設定值了。

例如現在只需要呼叫 AddOpenIdConnect 方法時設定 Authority

.AddOpenIdConnect("Google", o =>
{
    o.Authority = "https://accounts.google.com";
});

相關的 Middleware 就會自動在後台將其拼接為 https://accounts.google.com/.well-known/openid-configuration 並下載 JSON 資料。

同理可知如果我們需要使用 Microsoft 的 OIDC 服務可以在文檔找到設定網址為

https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration

接下來回到我們的專案直接將運行專案並且在瀏覽器輸入 https://localhost:5001/.well-known/openid-configuration 看看我們的設定檔有什麼內容

{
  "issuer": "https://localhost:5001",
  "jwks_uri": "https://localhost:5001/.well-known/openid-configuration/jwks",
  "authorization_endpoint": "https://localhost:5001/connect/authorize",
  "token_endpoint": "https://localhost:5001/connect/token",
  "userinfo_endpoint": "https://localhost:5001/connect/userinfo",
  "end_session_endpoint": "https://localhost:5001/connect/endsession",
  "check_session_iframe": "https://localhost:5001/connect/checksession",
  "revocation_endpoint": "https://localhost:5001/connect/revocation",
  "introspection_endpoint": "https://localhost:5001/connect/introspect",
  "device_authorization_endpoint": "https://localhost:5001/connect/deviceauthorization",
  "backchannel_authentication_endpoint": "https://localhost:5001/connect/ciba",
  "pushed_authorization_request_endpoint": "https://localhost:5001/connect/par",
  "require_pushed_authorization_requests": false,
  "frontchannel_logout_supported": true,
  "frontchannel_logout_session_supported": true,
  "backchannel_logout_supported": true,
  "backchannel_logout_session_supported": true,
  "scopes_supported": [
    "openid",
    "offline_access"
  ],
  "claims_supported": [
    "sub"
  ],
  "grant_types_supported": [
    "authorization_code",
    "client_credentials",
    "refresh_token",
    "implicit",
    "urn:ietf:params:oauth:grant-type:device_code",
    "urn:openid:params:grant-type:ciba"
  ],
  "response_types_supported": [
    "code",
    "token",
    "id_token",
    "id_token token",
    "code id_token",
    "code token",
    "code id_token token"
  ],
  "response_modes_supported": [
    "form_post",
    "query",
    "fragment"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post"
  ],
  "revocation_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post"
  ],
  "introspection_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "userinfo_signing_alg_values_supported": [
    "RS256"
  ],
  "introspection_signing_alg_values_supported": [
    "RS256"
  ],
  "subject_types_supported": [
    "public"
  ],
  "code_challenge_methods_supported": [
    "plain",
    "S256"
  ],
  "request_parameter_supported": true,
  "request_object_signing_alg_values_supported": [
    "RS256",
    "RS384",
    "RS512",
    "PS256",
    "PS384",
    "PS512",
    "ES256",
    "ES384",
    "ES512"
  ],
  "prompt_values_supported": [
    "none",
    "login",
    "consent",
    "select_account"
  ],
  "authorization_response_iss_parameter_supported": true,
  "backchannel_token_delivery_modes_supported": [
    "poll"
  ],
  "backchannel_user_code_parameter_supported": true,
  "backchannel_authentication_request_signing_alg_values_supported": [
    "RS256",
    "RS384",
    "RS512",
    "PS256",
    "PS384",
    "PS512",
    "ES256",
    "ES384",
    "ES512"
  ],
  "dpop_signing_alg_values_supported": [
    "RS256",
    "RS384",
    "RS512",
    "PS256",
    "PS384",
    "PS512",
    "ES256",
    "ES384",
    "ES512"
  ]
}

我們使用 Google 登入時第一步就是申請一組 ClientIdClientSecrets 並且新增一組 Scope api1 讓使用者知道我們有什麼服務可以使用,例如在 Google 的 scopes_supported 就有提供 email 可以讓我們申請使用, 不過申請是每個人都可以申請,但是可不可以使用是另一回事,所以我們需要新增 Client 當下同時設定 AllowedScopes 允許本 Client 可以使用 api1 這個服務。

Config.cs 新增一組 Client 與 ApiScopes,可以選擇新增 ApiResources 進一步限定存取範圍限制

using Duende.IdentityServer.Models;

namespace IdentityServer;

public static class Config
{
    public static IEnumerable<IdentityResource> IdentityResources =>
        new IdentityResource[]
        {
            new IdentityResources.OpenId()
        };

    public static IEnumerable<ApiScope> ApiScopes =>
        new List<ApiScope>
        {
            new ApiScope(name: "api1", displayName: "Access to MyAPI")
        };

    public static IEnumerable<ApiResource> ApiResources =>
        new List<ApiResource>
        {
            new ApiResource("my_backend_api", "My Main Backend API")
            {
                // 將 Api Scope 綁定到此 Resource
                Scopes = { "api1" },

                // 可指定此 API 需要哪些 User Claims
                UserClaims = { "email" }
            }
        };

    public static IEnumerable<Client> Clients =>
        new List<Client>
        {
            new Client
            {
                ClientId = "appClient",
                AllowedGrantTypes = GrantTypes.ClientCredentials,
                ClientSecrets =
                {
                    new Secret("secret".Sha256())
                },

                AllowedScopes = { "api1" }
            }
        };
}

接下來建立一個新的 api 專案並且到 launchSettings.json 將啟動的 port 改成 6001,我們需要將此 api 保護起來, 只允許有授權的人才能訪問。

dotnet new webapi -n Api
cd ..
dotnet sln add ./src/Api/Api.csproj

安裝 JwtBearer Package,因為傳入的 Access Token 為 JWT 格式,我們需要此函式庫方便把需要的資訊解析出來。

dotnet add ./src/Api/Api.csproj package Microsoft.AspNetCore.Authentication.JwtBearer

program.cs 設定 JWT 登入流程,並新增新的路由 identity 限制只有驗證過的會員才能使用, 下方的 Audience 需要跟 ApiResources 定義的 my_backend_api 完全相同,這樣可以在一收到 JWT 時馬上確認 此 Token 的使用目標是不是本 API。

using System.Security.Claims;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();

builder.Services.AddAuthentication()
    .AddJwtBearer(options =>
    {
        options.Authority = "https://localhost:5001";
        options.Audience = "my_backend_api";
    });

builder.Services.AddAuthorization();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.UseHttpsRedirection();

app.MapGet("identity", (ClaimsPrincipal user) => user.Claims.Select(c => new { c.Type, c.Value }))
    .RequireAuthorization();

app.UseAuthentication();
app.UseAuthorization();

app.Run();

最後建立一個新的 console 專案用來模擬前端請求

dotnet new console -n Client
cd ..
dotnet sln add ./src/Client/Client.csproj

安裝 Duende.IdentityModel 用來請求新的Token

dotnet add ./src/Client/Client.csproj package Duende.IdentityModel

接下來修改 Program.cs 我們的 console 專案,我們需要先去 IdentityServer 登入取得一組 JWT Token 才可以訪問保護的 API, 這裡的 GetDiscoveryDocumentAsync 會自動組裝 Url 並取得 JSON 設定檔。

並透過 RequestClientCredentialsTokenAsync 方法取得 Scope 為 api1 的 Token。

最後將此 Token 放在 Header 中存取 https://localhost:6001/identity 並順利輸出 Claim。

var client = new HttpClient();
var disco = await client.GetDiscoveryDocumentAsync("https://localhost:5001");
if (disco.IsError)
{
    Console.WriteLine(disco.Error);
    return;
}

var tokenResponse = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{
    Address = disco.TokenEndpoint,

    ClientId = "appClient",
    ClientSecret = "secret",
    Scope = "api1"
});

if (tokenResponse.IsError)
{
    Console.WriteLine(tokenResponse.Error);
    return;
}

Console.WriteLine(tokenResponse.AccessToken);

var apiClient = new HttpClient();
apiClient.SetBearerToken(tokenResponse.AccessToken);

var response = await apiClient.GetAsync("https://localhost:6001/identity");
if (!response.IsSuccessStatusCode)
{
    Console.WriteLine(response.StatusCode);
}
else
{
    var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
    Console.WriteLine(JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true }));
}

都設定完成後我們先運行 IdentityServer 與 API 專案,之後在運行 Console 專案, Console 專案就會帶著我們的 Token 去請求 API 專案,並且成功訪問被保護資訊。

eyJhbGciOiJSUzI1NiIsImtpZCI6IkZGRjY0QURENjI4RjFGOTJCNjY4Rjg0NzcwQzQ3QjlDIiwidHlwIjoiYXQrand0In0.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo1MDAxIiwibmJmIjoxNzY4ODI0MjMxLCJpYXQiOjE3Njg4MjQyMzEsImV4cCI6MTc2ODgyNzgzMSwiYXVkIjpbIm15X2JhY2tlbmRfYXBpIiwibXlfYmFja2VuZF9hcGkxIl0sInNjb3BlIjpbImFwaTEiXSwiY2xpZW50X2lkIjoiYXBwQ2xpZW50IiwianRpIjoiNURBMzkzQzFBRkY2NzdEQUIwNzZCNjgzNUExOUM4MzkifQ.UHVsUV9_hyzxzPT2OtV2jwzefJxDAEd8DZlH1zj2nRBQ1TBWevaSan_Nam1Qang3er2ooROYLsRLUvjljcIPS8jp_lpFYsS4txSuMUAsAu86kgF1m5Tp5DbEE9jIvIbTjZCyMKrrsq9BuVzPNqLJ3k8R49lPVIkErmBG15qKHuHI6R51wsIa7_fqfxbXCJqGuHQlaHfyidhsk4xYpyL4Fv-8vVf5CtmLwVg8MSRPRs_T6t8UaUEddGqoKa3zJzGo1ablQdUBX9riYdsCkZO3IYeHN8b9HrnTc2Aky4y8a940ZRXFb3emegUMi_qClRPs9QbjdxuH0sipd3w_HPfh3g
[
  {
    "type": "iss",
    "value": "https://localhost:5001"
  },
  {
    "type": "nbf",
    "value": "1768824231"
  },
  {
    "type": "iat",
    "value": "1768824231"
  },
  {
    "type": "exp",
    "value": "1768827831"
  },
  {
    "type": "aud",
    "value": "my_backend_api"
  },
  {
    "type": "scope",
    "value": "api1"
  },
  {
    "type": "client_id",
    "value": "appClient"
  },
  {
    "type": "jti",
    "value": "5DA393C1AFF677DAB076B6835A19C839"
  }
]

這裡的 aud 是由 Identity Server 根據 scope 找出對應的 ApiResource,也就是 my_backend_api。 如果沒有設定 Api Resource,則 aud 會自動設定為 https://localhost:5001/resources 會導致 API 驗證 Audience 時無法通過, 因此需要在 API 專案額外關閉 Audience 檢查。

這是 Identity Server 的預設行為,正式環境應明確定義 ApiResource,避免關閉 Audience 驗證。

builder.Services.AddAuthentication()
    .AddJwtBearer(options =>
    {
        options.Authority = "https://localhost:5001";
        options.TokenValidationParameters.ValidateAudience = false;
    });

Summary

今天學習了 Identity Server 最基礎的用法,之後只要統一使用 Identity Server 發行認證 Token, 我們就可以拿著這個 Token 到處去訪問 API 了,並不需要每個 API 都建立一套授權系統。