Identity Server UI
在上一篇的文章中 我們跟著 Identity Server 的官方教學做了一遍,不過用起來跟 Google 好像不太一樣 我們之前使用 Google 外部登時會把我們瀏覽器轉跳到 Google 並且要求我們的同意
因為我們昨天建立的專案為最基礎的專案,所以並沒有把一些 UI 添加進來今天就來試試看幫我們的專案添加 UI
轉跳到 IdentityServer 專案並且運行命令
cd .\IdentityServer\
dotnet new duende-is-ui
dotnet new isui
只會幫忙添加一些 Razor 頁面以及 css js,並不會建立一個獨立專案
想要一口氣建立 UI 以及 Server 可以改用以下命令
dotnet new duende-is-inmem
運行完成後會在專案底下發現多了兩個資料夾
Pages
wwwroot
接著開啟 HostingExtensions.cs
檔案會發現有幾個之前就存在的註釋
因為 IdentityServer 已經預留好了所以我們這邊只需要將註釋解除即可
// uncomment if you want to add a UI
builder.Services.AddRazorPages();
// uncomment if you want to add a UI
app.UseStaticFiles();
app.UseRouting();
// uncomment if you want to add a UI
app.UseAuthorization();
app.MapRazorPages().RequireAuthorization();
MapRazorPages().RequireAuthorization()
方法代表所有 Razor 頁面都需要權限
Scope
接下來修改 Config.cs
需要在 IdentityResources 添加新的 Resource
public static IEnumerable<IdentityResource> IdentityResources =>
new List<IdentityResource>
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
};
這邊 IdentityResources 與 ApiScopes 概念類似兩者都歸類在 Scope 請求底下 舉個例子當我們按下外部登入時程式會組裝出以下連結
https://accounts.google.com/o/oauth2/v2/auth
?client_id=4346799237-7d033l9pdco0rjwvo0j2blvk3k7eqltg.apps.googleusercontent.com
&redirect_uri=https://localhost:7011/signin-google
&response_type=code
&scope=openid profile email
&code_challenge=EYk_nrePzsLEMnZ6odivW-7yepzTHQtcy9x48wLz8o0
&code_challenge_method=S256
&response_mode=form_post
&nonce=638186960320580862.NGFiZjVkN2UtMjZjYS00YWMzLWFkMDMtMTlkMDFlY2Y3ZTgyMDEyMGU4ZGMtOTgwYi00OGNlLWIzOTctMWVlZjcxODcxODYw
&state=CfDJ8N83xunRSMFEnHYjbQSeE5T18RR0kTdV2V8JG07eDwG13aShQ_yquxFbPL5XzwKeNyzT8Ci07de8DKn48qgsi_ENWwRhsMgoS9IS30rfj8mOa5JGuhbWo3Y-TcH_JgylNxhiVqFLhLezHwpA23YaViBsIGYIOR4_RrVp02Gc58BwBXKcDO5cLAbLU6414eL8OGd7By1MX6ps3T7K8m21bwH2V4F7ypDq4tPzv2myzGiY_JlmmYs0lro0tElXUgrYnPoizaPe5V-qIxsfotbFWjVFpReH1he_bQ-NnVPfT2gIuleH518imRJl9LolRiFFCz7nOJ559xZBOV0XVxYNi6NDYvCYacl5PFDyhNFDR9wq9IYuQmufIMJR09vcmVNLJ3uj7f0ap55hU9b4Pt6tneJjc0G1LJk8OIvze-aRrh9j
&x-client-SKU=ID_NETSTANDARD2_0
&x-client-ver=6.15.1.0
這之中有 scope 請求參數 scope=openid profile email
根據 OpenID Connect 內提到的規範
openid 有強制規範是需要添加的,所以一個最基本的 oidc 請求一定會看到 scope=openid
要添加其他參數需要最後新增一個空格並且打上規範的參數
能添加什麼值其實在文檔內部也已經規範好了,可以參考
由以上兩份文件連結可以得出我們還可以添加以下幾個值
- profile
- address
- phone
- offline_access
所以不管是那一套軟體只要有遵守 OIDC 規範的軟體你都會在 /.well-known/openid-configuration
設定中 scopes_supported
看到以上幾個值,如果沒有出現就代表那個廠商不允你使用
舉例來說我寫了一個程式想要取得 Google 會員的 email 首先我需要檢查 Google 的 /.well-known/openid-configuration
根據結果 Google 只允許我使用以下幾個參數,email 就是允許的其中之一
"scopes_supported": [
"openid",
"email",
"profile"
],
最後我須要在 scope 中添加 scope=openid email
這動作是跟 Google 申請說我想要看會員的 email
Google 檢查 email 這個是允許查看的,最後才到 UserInfo Endpoint 取得資料
還記得上一篇文章添加的 ApiScopes api1
嗎,一開始有提到 ApiScopes 也是歸類在 Scope 請求底下
這邊就是規範以外的值了,意思也是允使我們應用程式能夠使用哪幾些資源,例如我可以建立一個 payment
Api
並且也是添加在 Scope 後方 scope=openid email payment
這樣就可以去跟我們的 Identity Server 申請我們想要使用 payment
Api
回到 Config.cs
IdentityResources 可以添加的值有五個分別為,分別對應到剛剛說的內容
- OpenId
- Profile
- Address
- Phone
由於我們已經添加 Profile 到我們專案接下來將專案運行起來
運行專案之後可以在我們專案的 /.well-known/openid-configuration
設定中的 scopes_supported
看到以下內容
這邊 offline_access
預設是會直接添加進來的
"scopes_supported": [
"openid",
"profile",
"api1",
"offline_access"
],
發現 api1
也會被包含在 scopes_supported
裡面這也是為什麼上一篇的文章我們可以使用 api1
是因為 IdentityServer 有允許申請者使用 api1
並且我們在請求 IdentityServer 時也有說我們想要使用 api1
IdentityServer 確認後最終會給我們一個 JWT 內容有說明我們有權使用 api1
最後在 API 專案添加 JWT Package
專案會有能力驗證我們帶入的 JWT 證實我們擁有使用 api1
的權限
了解背後原理後我們開啟 TestUsers.cs
在這個檔案中有定義兩個會員 alice
和 bob
,就如同我們在 Google 註冊新帳號的流程一樣
new TestUser
{
SubjectId = "1",
Username = "alice",
Password = "alice",
Claims =
{
new Claim(JwtClaimTypes.Name, "Alice Smith"),
new Claim(JwtClaimTypes.GivenName, "Alice"),
new Claim(JwtClaimTypes.FamilyName, "Smith"),
new Claim(JwtClaimTypes.Email, "AliceSmith@email.com"),
new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean),
new Claim(JwtClaimTypes.WebSite, "http://alice.com"),
new Claim(JwtClaimTypes.Address, JsonSerializer.Serialize(address), IdentityServerConstants.ClaimValueTypes.Json)
}
},
開啟 HostingExtensions.cs
呼叫 AddTestUsers
將剛剛定義的會員添加到系統內
builder.Services.AddIdentityServer(options =>
{
// https://docs.duendesoftware.com/identityserver/v6/fundamentals/resources/api_scopes#authorization-based-on-scopes
options.EmitStaticAudienceClaim = true;
})
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryClients(Config.Clients)
.AddTestUsers(TestUsers.Users);
此時我們將專案運行起來並訪問 https://localhost:5001/
會看到預設添加的首頁,我們點擊 Click here to see the claims for your current session.
因為每個 Razor Page 都要求有權限所以會直接轉跳到登入頁面,此時我們可以使用剛剛建立的兩個會員來登入(帳號:alice/密碼:alice)
接下來到 Config.cs
設定一個新的 Client
這裡的 RedirectUris 跟我們在 Google 申請 OAuth 的用途一樣都是用來轉跳回前端頁面
new Client
{
ClientId = "web",
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedGrantTypes = GrantTypes.Code,
// where to redirect to after login
RedirectUris = { "https://localhost:5002/signin-oidc" },
// where to redirect to after logout
PostLogoutRedirectUris = { "https://localhost:5002/signout-callback-oidc" },
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile
}
}
這次我們添加一個 Razor 專案來模擬前端請求頁面
dotnet new webapp -n WebClient
cd ..
dotnet sln add ./src/WebClient/WebClient.csproj
WebClient 專案新增 OpenIdConnect Package
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
接下來在 Program.cs
添加 Cookie 與 OpenId 這部份可以參考以前的文章有詳細討論過
注意目前並沒有整合 .NET Identity 所以並不需要 IdentityConstants.ExternalScheme 保存三方登入結果
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultChallengeScheme = "IdentityServerOidc";
})
.AddCookie(IdentityConstants.ApplicationScheme)
.AddOpenIdConnect("IdentityServerOidc", options =>
{
options.Authority = "https://localhost:5001";
options.ClientId = "web";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.GetClaimsFromUserInfoEndpoint = true;
options.SaveTokens = true;
});
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages().RequireAuthorization();
這邊我們將每個頁面都設定需要權限才能觀看,如果我們打開任何一個頁面會先檢查瀏覽器 Cookie DefaultScheme 看看存不存在
之後發起 Challenge 也就是這裡設定的 IdentityServerOidc
並且運行 HandleChallengeAsync 將我們的頁面轉跳到 IdentityServer
修改 Pages/Index.cshtml
的內容,會去讀取目前登入的會員並且列出所有的 Claim
@page
@model IndexModel
@using Microsoft.AspNetCore.Authentication
<h2>Claims</h2>
<dl>
@foreach (var claim in User.Claims)
{
<dt>@claim.Type</dt>
<dd>@claim.Value</dd>
}
</dl>
<h2>Properties</h2>
<dl>
@foreach (var prop in (await HttpContext.AuthenticateAsync()).Properties.Items)
{
<dt>@prop.Key</dt>
<dd>@prop.Value</dd>
}
</dl>
完成後先將 IdentityServer 專案之後運行 WebClient,瀏覽器輸入 WebClient 的網址因為有阻擋的原因
頁面會先轉跳到 IdentityServer https://localhost:5001/
要求我們先進行登入,完成登入之後會在次
轉跳回 WebClient 並且列出會員所有的 Claim
成功登入後我們還需要登出頁面,我們登入成功會在瀏覽器紀錄一個 cookie .AspNetCore.Identity.Application
要登出只要直接將此 cookie 清除即可完成,另外如果 Oidc Server 有支援 end_session_endpoint
可以另外到 Oidc Server
清除遠端服務器的 cookie 最後在轉跳回原頁面
dotnet new page -n Signout
public class SignoutModel : PageModel
{
public IActionResult OnGet()
{
return SignOut(IdentityConstants.ApplicationScheme);
}
}
最後到 _Layout.cshtml
在 navbar 新增登出按鈕
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/Signout">Signout</a>
</li>
Summary
今天學習了如何讓前端頁面使用 IdentityServer 進行登入,有了這套模式後就不用每個 APP 都建立自己的 登入登出邏輯了,任何前端只要安裝 oidc package 後就能共用 IdentityServer 裡面的會員資料非常方便