自訂.NET Core Identity身份驗證和授權教學:Identity Server 自訂.NET Core Identity身份驗證和授權教學:Identity Server

Published on Wednesday, May 3, 2023

Identity Server

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

dotnet new install Duende.IdentityServer.Templates

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

Duende.IdentityServer.Templates (版本 6.2.0) 已安裝,將會以 最新版本 取代。
Duende.IdentityServer.Templates::6.2.0 已成功解除安裝。
成功: Duende.IdentityServer.Templates::6.2.0 已安裝下列範本:
範本名稱                                                    簡短名稱  語言  標記
----------------------------------------------------------  --------  ----  ------------------
Duende BFF with JavaScript                                  bffjs     [C#]  Web/IdentityServer
Duende IdentityServer Empty                                 isempty   [C#]  Web/IdentityServer
Duende IdentityServer Quickstart UI (UI assets only)        isui      [C#]  Web/IdentityServer
Duende IdentityServer with ASP.NET Core Identity            isaspid   [C#]  Web/IdentityServer
Duende IdentityServer with Entity Framework Stores          isef      [C#]  Web/IdentityServer
Duende IdentityServer with In-Memory Stores and Test Users  isinmem   [C#]  Web/IdentityServer

接下來我們就照著官方文檔來過一遍 [Doc]https://docs.duendesoftware.com/identityserver/v6/quickstarts/1_client_credentials/) 先建立一個 Duende IdentityServer Empty 範本專案並添加到方案內,簡短名稱為 isempty

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

cd src
dotnet new isempty -n IdentityServer

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

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

  • IdentityServer 專案安裝了 Duende.IdentityServer Nuget Package
  • 專案啟動網址預設為 https://localhost:5001
  • Program.cs 內部的設定移動到 HostingExtensions.cs 類似之前的 Startup.cs
  • 呼叫 AddIdentityServer 方法註冊要用到的 Services 到 IServiceCollection
  • 呼叫 UseIdentityServer 方法註冊要用到的 Middleware 到 IApplicationBuilder
  • 新增了一個 static class Config.cs 裡面有三個預設的 Property IdentityResources ApiScopes Clients 在設定 IServiceCollection 會來讀取這些設定值

OIDC Discovery Documents

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

  1. 呼叫後端 ExternalSignin 方法會執行 HttpContext Challenge 方法並帶著設定的 ClientId和Scope(openid 和 email)與一些設定產生 Google 網址
  2. 瀏覽器將轉跳到我們生成的 Google 授權服務器網址
  3. 我們會在瀏覽器看到授權頁面如果我們同意就按下確認授權
  4. Google 授權服務器會帶著 Code 並且轉跳回後端網址
  5. 後端帶著 Code 去 Google Token服務器取得 Access Token 與 ID Token
  6. 轉跳回 ExternalLoginCallback 方法

首先第一個問題是我們的程式是怎麼知道要去那一台伺服器的,明明當初註冊的時候沒有進行設定也不像之前提到 OAuth 時微軟有幫忙建立 GoogleOptions 來紀錄這些資訊

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

    ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
    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 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/v2/userinfo";

我們現在改註冊的是 OIDC 所以並沒有這些資料,之前有提到 OIDC 只是建立在 OAuth 之上的封裝並且建立了許多規範要大家遵守 所以針對這個問題 OIDC 也有提出相對應的規範,我們先查看一下當初設定的網址 Authority https://accounts.google.com

這個規範叫做 Discovery Documents 所有符合規範的服務必須提供一個 Endpoint 並且回傳一個 json 設定值用來提供之後驗證流程使用 要使用這個 Endpoint 我們只需要在網址後面加上 /.well-known/openid-configuration 就能看到相關設定 以 google 為例,我們需要呼叫 https://accounts.google.com/.well-known/openid-configuration 就會回傳以下內容

{
  "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"
  ],
  "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",
    "locale",
    "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"
  ]
}

有了這個設定文檔後就不需要在額外依賴 GoogleOptions 來紀錄這些設定值了 每個設定值都有詳細的規範內容可以參考 文檔

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

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

Github 的網址為

https://token.actions.githubusercontent.com/.well-known/openid-configuration
```'

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

``` text
{
  "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",
  "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"
  ],
  "id_token_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",
    "HS256",
    "HS384",
    "HS512"
  ],
  "authorization_response_iss_parameter_supported": true,
  "backchannel_token_delivery_modes_supported": [
    "poll"
  ],
  "backchannel_user_code_parameter_supported": true
}

了解基礎知識後我們來進行實做,在我們使用 Google 登入時第一步就是申請一組 ClientIdClientSecrets 並且新增一組 Scope api1 讓使用者知道我們有什麼服務可以使用,例如在 Google 的 scopes_supported 就有提供 email 可以讓我們申請使用 不過申請是每個人都可以申請,但是可不可以使用是另一回事所以我們同時在 Client 設定 AllowedScopes 允許這組 ClientId 可以使用 api1 這個服務

Config.cs 新增一組 Client 與 ApiScopes ClientId 為 client ClientSecrets 為 secret GrantTypes 使用 ClientCredentials 通常用在 Server 與 Server 之間的驗證 AllowedScopes 允許使用 api1

public static IEnumerable<ApiScope> ApiScopes =>
    new List<ApiScope>
    {
        new ApiScope(name: "api1", displayName: "MyAPI") 
    };
    
public static IEnumerable<Client> Clients =>
    new List<Client>
    {
        new Client
        {
            ClientId = "client",

            // no interactive user, use the clientid/secret for authentication
            AllowedGrantTypes = GrantTypes.ClientCredentials,

            // secret for authentication
            ClientSecrets =
            {
                new Secret("secret".Sha256())
            },
            
            // scopes that client has access to
            AllowedScopes = { "api1" }
        }
    };

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

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

此 API 需要安裝 JwtBearer Package,不使用 cookie

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

program.cs 設定 JWT 登入流程

builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", options =>
    {
        options.Authority = "https://localhost:5001";

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = false
        };
    });

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

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

安裝 IdentityModel 用來請求新的Token

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

接下來修改 Program.cs 我們的 console 專案,我們需要先去 IdentityServer 登入取得一組 JWT Token 才可以訪問保護的 API

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 = "client",
    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 專案,並且成功訪問被保護資訊

eyJhbGciOiJSUzI1NiIsImtpZCI6IjJGNDRBMzNBNDdBOTk0MTE5NkUwQ0U1QTU2N0MyM0NFIiwidHlwIjoiYXQrand0In0.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo1MDAxIiwibmJmIjoxNjgzMTI0OTU3LCJpYXQiOjE2ODMxMjQ5
NTcsImV4cCI6MTY4MzEyODU1NywiYXVkIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6NTAwMS9yZXNvdXJjZXMiLCJzY29wZSI6WyJhcGkxIl0sImNsaWVudF9pZCI6ImNsaWVudCIsImp0aSI6IjRDQUQwNzI5RUUwRDRENjM1OEMzOTM4RDgzM0E0
MEE3In0.QpNK9WB7P1VCHCCQa7zVtOezII_PUXa-Gto7ohz62y2rZXoGKXK_3OUTdqBGOosXOUgtbmHL5maRKEIXB_6Azec2gqpc7AiT6i1364CTQy2hCisi9MmCIzgHX0g9WAOoTynrYhhGBdf6Lbj0LvUefRLiU4WOWbHm9eam0AT4BL-Y
i5reHJPeEOMvghvH-Z9LBDRA9WsVf-CDml74fKNHziArYFdopoBHrY8oeVzZlCGtjDfEbnVI82AXVCjDWeewQjYvWKjvh_P5Dtsf2yvdwqxiMsWlROaQKLACHKaVELYJe-cvDU_uRriOnAir5wJKXPy2wM69G2xaXooi_1VCfg
[
  {
    "type": "iss",
    "value": "https://localhost:5001"
  },
  {
    "type": "nbf",
    "value": "1683124957"
  },
  {
    "type": "iat",
    "value": "1683124957"
  },
  {
    "type": "exp",
    "value": "1683128557"
  },
  {
    "type": "aud",
    "value": "https://localhost:5001/resources"
  },
  {
    "type": "scope",
    "value": "api1"
  },
  {
    "type": "client_id",
    "value": "client"
  },
  {
    "type": "jti",
    "value": "4CAD0729EE0D4D6358C3938D833A40A7"
  }
]

Summary

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

完整程式可以到官方Github查看 Github