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

Published on Friday, May 5, 2023

Identity Server UI

在上一篇的文章我們跟著 Identity Server 的官方教學照做了一遍,不過用起來跟 Google 好像不太一樣 我們之前使用 Google 外部登時會把我們瀏覽器轉跳到 Google 並且要求我們的同意

因為我們昨天建立的專案為最基礎的專案,所以並沒有把一些 UI 添加進來今天就來試試看幫我們的專案添加 UI

轉跳到 IdentityServer 專案並且運行命令

cd .\IdentityServer\
dotnet new isui

dotnet new isui 只會幫忙添加一些 Razor 頁面以及 css js,並不會建立一個獨立專案 想要一口氣建立 UI 以及 Server 可以改用以下命令

dotnet new isinmem

dotnet new isui 運行完成後會在專案底下發現多了兩個資料夾

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();

為了使用 Razor Pages 模板這些都是必要的方法 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
  • email
  • 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
  • Email
  • 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 在這個檔案中有定義兩個會員 alicebob,就如同我們在 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 這部份可以參考以前的文章有詳細討論過

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("Cookies", "oidc");
    }
}

最後到 _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 裡面的會員資料非常方便