Identity Server UI
在上一篇的文章中
我們架設了一台自己的授權伺服器,由於目前使用的是 ClientCredentials 模式所以並不需要外部用戶的參與。
但在更常用的 AuthorizationCode 模式中就需要資源的擁有者同意應用程式獲取私人資訊,
就例如使用 Google 登入時會把我們瀏覽器轉跳到 Google 並且跳出權限頁面要求我們的同意。
很明顯目前我們的 Identity Server 沒有任何的頁面,這部分可以自己處理客製化頁面也可以直接安裝模板提供的 UI 頁面, 今天就測試把 UI 模板添加進來專案中。
首先回到 IdentityServer 專案並且運行命令,背後只會添加預設的 Razor 頁面以及 css、js,並不會建立一個獨立專案。
cd .\src\IdentityServer\
dotnet new duende-is-ui
注意添加完 UI 後還要設定 Middleware 否則不會生效,
接下來開啟 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 頁面都需要權限,因此只有 AllowAnonymous 屬性的頁面才可以訪問。
完成後運行 IdentityServer 專案就會發現首頁被替換成預設的歡迎頁面了。
Scope 與 Claim
由於 AuthorizationCode 模式是資源擁有者將自己的資料授權給第三方應用程式讀取,
所以現在要想辦法開放第三方應用程式能夠跟 IdentityServer 申請我們的個人資料相關的功能,
為了資料安全所以申請個人資料預設沒有開啟,所以需要我們手動設定。
根據文檔 5.4.Requesting Claims using Scope Values 得知跟個人資料相關的 scope 值有五個分別為:
- OpenId
- Profile
- Address
- Phone
這裡的 scope 是代表一個資料申請範圍,例如申請 Profile 那麼就會在回傳的 JWT 中添加 name、family_name、birthdate 等資料,
申請 email 就會添加 email、email_verified 資料。
在 OpenId 這些個人資料被叫做 Claims,可以想像成身份證中欄位,我們會在定義會員時再次看到這些資訊。
回到 Config.cs 在 IdentityResources 添加所有 IdentityResource。
public static IEnumerable<IdentityResource> IdentityResources =>
new IdentityResource[]
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResources.Email(),
new IdentityResources.Phone(),
new IdentityResources.Address()
};
接下來運行專案可以在 /.well-known/openid-configuration 中看到 scopes_supported 添加了額外的 Scope,
最下方的 offline_access 是預設直接添加進來的,還有看到上一篇文章設定的 api1 Scope。
"scopes_supported": [
"openid",
"profile",
"email",
"phone",
"address",
"api1",
"offline_access"
]
要注意 IdentityResources 代表的是 Identity Scope 主要是用來顯示個人相關資訊,回傳的資訊會保存在 ID Token 中,
我們的 api1 是 API Scope 主要是用來辨識存取權限,這部分的資訊會保存在 Access Token 中。
這也是為什麼上一篇的文章我們可以使用 api1 Scope,
IdentityServer 會給我們一個 Access Token 內部說明我們有權使用 api1,
最後 API 專案透過 JWT Package 驗證帶入的 Access Token 證實我們確實擁有使用 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);
此時我們將專案運行起來並點擊 Click here to see the claims for your current session.
由於目前還沒有登入並且每個 Razor Page 都需要權限所以會直接轉跳到登入頁面,此時我們可以使用剛剛建立的會員來登入(帳號:alice/密碼:alice)
接下來到 Config.cs 設定一個新的 Client,並使用 AuthorizationCode 模式,
這裡先預設跳轉回 Port 5002,等等建立的新專案要使用這個 Port,
並且允許這個 Client 申請 OpenId、Profile。
new Client
{
ClientId = "web",
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedGrantTypes = GrantTypes.Code,
RequireConsent = true,
RedirectUris = { "https://localhost:5002/signin-oidc" },
PostLogoutRedirectUris = { "https://localhost:5002/signout-callback-oidc" },
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile
}
}
注意之前只是開啟 IdentityServer 的全局 Scope 設定,這裡需要在 Client 設定允許的 Scope 清單。
現在我們添加一個 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 保存三方登入結果,
這裡指定 DefaultChallengeScheme 直接指定使用自己定義的 IdentityServerOidc 這樣只要一發起 Challenge
就會連線到自己的 IdentityServer。
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 看看存不存在, 發現沒有登入 cookie 時會自動發起 Challenge 將我們的頁面轉跳到 IdentityServer。
修改 Pages/Index.cshtml 的內容,會去讀取目前登入的會員並且列出所有的 Claim
@page
@model IndexModel
@using Microsoft.AspNetCore.Authentication
@{
var result = await HttpContext.AuthenticateAsync();
}
@if (!result.Succeeded)
{
<h2>Welcome (Guest)</h2>
<form method="post" asp-page-handler="Login">
<button class="btn btn-primary btn-lg">使用 IdentityServer 登入</button>
</form>
}
else
{
<h2>Claims</h2>
<dl>
@foreach (var claim in User.Claims)
{
<dt>@claim.Type</dt>
<dd>@claim.Value</dd>
}
</dl>
<h2>Properties</h2>
<dl>
@if (result.Properties?.Items != null)
{
foreach (var prop in result.Properties.Items)
{
<dt>@prop.Key</dt>
<dd>@prop.Value</dd>
}
}
</dl>
}
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace WebClient.Pages;
[AllowAnonymous]
public class IndexModel : PageModel
{
public void OnGet()
{
}
public IActionResult OnPostLogin()
{
return Challenge(new AuthenticationProperties { RedirectUri = "/" }, "IdentityServerOidc");
}
}
最後要確認將 WebClient 的 Port 改成 5002。
完成後先運行 IdentityServer 專案之後運行 WebClient,瀏覽器顯示 Index.cshtml 內容,
點擊登入後轉跳到 IdentityServer https://localhost:5001/ 要求我們進行登入(帳號:alice/密碼:alice),
由於我們有開啟 RequireConsent,因此需要在 User profile 選項打勾,允許這個資料被外部讀取,
完成登入之後會馬上轉跳回 WebClient 並且列出會員所有的 Claim。
我們登入成功會在瀏覽器紀錄一個 cookie .AspNetCore.Identity.Application,要注意這個 cookie 是頒發給
WebClient 使用的,因此直接到 F12 清除掉此 cookie 只會將 WebClient 登出,並不影響 IdentityServer 的登入狀態,
所以你會發現清除後刷新頁面會再次連線到 IdentityServer 並取得新的 Token。
成功登入後我們還需要登出頁面, 注意需要將 WebClient 的 cookie 還要清除遠端服務器的 cookie 最後在轉跳回原頁面。
dotnet new page -n Signout
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace WebClient.Pages
{
public class SignoutModel : PageModel
{
public IActionResult OnGet()
{
return SignOut(
new AuthenticationProperties
{
RedirectUri = "/"
},
new[] { IdentityConstants.ApplicationScheme, "IdentityServerOidc" });
}
}
}
最後到 _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 函式庫就能共用 IdentityServer 裡面的會員資料非常方便。