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.IdentityServerNuget Package AddIdentityServer方法註冊 ServicesUseIdentityServer方法註冊 Middleware- 新增靜態類別
Config.cs裡面有三個的 PropertyIdentityResourcesApiScopesClients在設定 Application Builder 時讀取這些設定值
OIDC Discovery Documents 與伺服器自動設定
在開始寫程式之前先重新了解一下我們之前專案 IdentityDapper 的登入流程
- 呼叫後端
LoginGoogle方法,透過Challenge搭配 ClientId、Scope(例如 openid、email)以及其他設定,生成 Google 授權網址。 - 瀏覽器跳轉到剛剛生成的 Google 授權網址。
- 使用者在 Google 授權頁面上同意存取權限。
- Google 授權伺服器回傳授權碼(Code),並將瀏覽器跳轉到後端指定的 Callback 網址。
- 後端使用這個 Code 向 Google Token 伺服器請求取得 Access Token 和 ID Token。
- 完成驗證後,瀏覽器轉跳至後端的 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 登入時第一步就是申請一組 ClientId 與 ClientSecrets
並且新增一組 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 都建立一套授權系統。