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
裡面有三個預設的 PropertyIdentityResources
ApiScopes
Clients
在設定 IServiceCollection 會來讀取這些設定值
OIDC Discovery Documents
在開始寫程式之前先重新了解一下我們之前專案 IdentityDapper
的登入流程
- 呼叫後端
ExternalSignin
方法會執行 HttpContext Challenge 方法並帶著設定的 ClientId和Scope(openid 和 email)與一些設定產生 Google 網址 - 瀏覽器將轉跳到我們生成的 Google 授權服務器網址
- 我們會在瀏覽器看到授權頁面如果我們同意就按下確認授權
- Google 授權服務器會帶著 Code 並且轉跳回後端網址
- 後端帶著 Code 去 Google Token服務器取得 Access Token 與 ID Token
- 轉跳回
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 登入時第一步就是申請一組 ClientId
與 ClientSecrets
並且新增一組 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