Identity Server With ASP.NET Core Identity

到目前為止 Identity Server 基礎部份都學習過了,但是還缺少會員的添加與管理方式,之前我們使用 TestUsers 寫死兩個會員資料, 但在實務中還是要有動態新增會員的方式,這時候就可以加入 ASP.NET Core Identity 功能,不過要處理這部份需要對 ASP.NET Core Identity 有相當程度的了解, 本系列一開始有討論過了有需要的可以回去看看。

要在 IdentityServer 使用 ASP.NET Core Identity 可以使用預設的範本 duende-is-aspid 來建立專案

先建立一個新專案來看看跟我們目前的專案有什麼區別

dotnet new duende-is-aspid -n IdentityServerAspNetIdentity
  1. 安裝 Package Duende.IdentityServer.AspNetIdentity, Microsoft.AspNetCore.Identity.EntityFrameworkCore, Microsoft.AspNetCore.Identity.UI
  2. wwwroot 與 Pages 資料夾都是 isui 這個範本提供的會幫你在專案添加一些預設頁面
  3. Models 資料夾新建立一個自定義的 IdentityUser 叫做 ApplicationUser
  4. Data 資料夾新建立一個自定義的 IdentityDbContext 叫做 ApplicationDbContext
  5. SeedData 內部會使用 UserManager 提供的方法來新增預設會員數據
  6. HostingExtensions 注入並使用 ApplicationDbContext
  7. 額外呼叫 AddAspNetIdentity 方法設定 Identity 相關功能並且呼叫 AddResourceOwnerValidatorAddProfileService

了解後回到原本的 IdentityServer 專案我們來進行實做

首先添加需要用到的 Package

dotnet add package Duende.IdentityServer.AspNetIdentity
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.AspNetCore.Identity.UI

建立 DbContext

namespace IdentityServer.Data;

public class AppIdentityDbContext : IdentityDbContext<IdentityUser>
{
    public AppIdentityDbContext(DbContextOptions<AppIdentityDbContext> options) : base(options)
    {
        
    }
}

將 AppIdentityDbContext 註冊到系統內,並且將 AddTestUsers 替換成 AddAspNetIdentity

const string connectionIdentity = @"User ID=postgres;Password=myPassw0rd;Host=localhost;Port=5432;Database=IdentityServerIdentity;Pooling=false;";

builder.Services.AddDbContext<AppIdentityDbContext>(options =>
    options.UseNpgsql(connectionIdentity));

builder.Services.AddIdentity<IdentityUser, IdentityRole>()
    .AddDefaultTokenProviders()
    .AddEntityFrameworkStores<AppIdentityDbContext>();
    
builder.Services.AddIdentityServer()
    .AddConfigurationStore(options =>
    {
        options.ConfigureDbContext = b => b.UseNpgsql(connectionStringConfigure,
            sql => sql.MigrationsAssembly(migrationsAssembly));
    })
    .AddOperationalStore(options =>
    {
        options.ConfigureDbContext = b => b.UseNpgsql(connectionStringOperational,
            sql => sql.MigrationsAssembly(migrationsAssembly));
    })
    .AddAspNetIdentity<IdentityUser>()
    .AddLicenseSummary();

因為要使用 Identity UI 我們需要在專案添加 /Views/Shared/_LoginPartial.cshtml 用來顯示右上角 根據登入狀態顯示 Login 或者 Log out,

cd Pages\Shared\
dotnet new page -np -n _LoginPartial
@if (User.Identity.IsAuthenticated)
{
    <form asp-controller="Account" asp-action="Logout" asp-route-returnUrl="@Url.Action("Index", "Home", new { area = "" })" method="post" id="logoutForm" class="navbar-right">
        <ul class="nav navbar-nav navbar-right">
            <li class="navbar-text">Hello @User.Identity.Name!</li>
            <li>
                <button type="submit" class="btn btn-link navbar-btn navbar-link">Log out</button>
            </li>
        </ul>
    </form>
}
else
{
    <ul class="nav navbar-nav navbar-right">
        <li><a asp-controller="Account" asp-action="Login">Login</a></li>
    </ul>
}

最後建立遷移腳本並且更新資料庫

dotnet ef migrations add InitialIdentityDbMigration -c AppIdentityDbContext -o Migrations/IdentityDb
dotnet ef database update -c AppIdentityDbContext
public static void SeedIdentityData(WebApplication app)
{
    using (var scope = app.Services.GetRequiredService<IServiceScopeFactory>().CreateScope())
    {
        var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
        var alice = userMgr.FindByNameAsync("alice").Result;
        if (alice == null)
        {
            alice = new IdentityUser
            {
                UserName = "alice",
                Email = "AliceSmith@example.com",
                EmailConfirmed = true,
            };
            var result = userMgr.CreateAsync(alice, "Pass123$").Result;
            if (!result.Succeeded)
            {
                throw new Exception(result.Errors.First().Description);
            }

            result = userMgr.AddClaimsAsync(alice, new Claim[]{
                        new Claim(JwtClaimTypes.Name, "Alice Smith"),
                        new Claim(JwtClaimTypes.GivenName, "Alice"),
                        new Claim(JwtClaimTypes.FamilyName, "Smith"),
                        new Claim(JwtClaimTypes.WebSite, "http://alice.example.com"),
                    }).Result;
            if (!result.Succeeded)
            {
                throw new Exception(result.Errors.First().Description);
            }
            Log.Debug("alice created");
        }
        else
        {
            Log.Debug("alice already exists");
        }

        var bob = userMgr.FindByNameAsync("bob").Result;
        if (bob == null)
        {
            bob = new IdentityUser
            {
                UserName = "bob",
                Email = "BobSmith@example.com",
                EmailConfirmed = true
            };
            var result = userMgr.CreateAsync(bob, "Pass123$").Result;
            if (!result.Succeeded)
            {
                throw new Exception(result.Errors.First().Description);
            }

            result = userMgr.AddClaimsAsync(bob, new Claim[]{
                        new Claim(JwtClaimTypes.Name, "Bob Smith"),
                        new Claim(JwtClaimTypes.GivenName, "Bob"),
                        new Claim(JwtClaimTypes.FamilyName, "Smith"),
                        new Claim(JwtClaimTypes.WebSite, "http://bob.example.com"),
                        new Claim("location", "somewhere")
                    }).Result;
            if (!result.Succeeded)
            {
                throw new Exception(result.Errors.First().Description);
            }
            Log.Debug("bob created");
        }
        else
        {
            Log.Debug("bob already exists");
        }
    }
}

ConfigurePipeline 內部呼叫此方法

SeedIdentityData(app);

要注意之前新增的 UI 是寫死依賴 TestUserStore,所以要將這些依賴替換成實際的 UserManagerSignInManager, 才是真正讀取資料庫進行登入,這部分可以參考一開始建立的 IdentityServerAspNetIdentity 模板專案,裡面已經修改完畢。

完成後運行 Identity Server 與 WebClient 專案進行測試

在瀏覽器打開 https://localhost:5002 會直接透過 OIDC 轉跳到 https://localhost:5001 要求登入, 這時輸入剛剛新增的帳號(alice, Pass123$)能夠正常登入並且跳回到 5002 並顯示目前 User 身上擁有的 Claim。


Summary

今天成功將 Identity Server 與 ASP.NET Core Identity 兩個技術結合在一起, 至此我們的功能已經可以做到類似 Google 的 OIDC 伺服器的提供的功能了, Identity Server 還有許多設定可以調整,可以按照自己的需求來做設定