Identity Server EntityFramework Core

在上一篇的文章中 已經成功架設了 Identity Server 服務與 UI 但是目前資料都是保存在記憶體內,今天來看看該怎麼把會員資料透過 EFCore 保存到資料庫內。

首先先回顧一下當初 Identity Server 使用的設定,在這裡我們呼叫了三個 AddInMemory 方法將對應的資料添加到記憶體內。

builder.Services.AddIdentityServer()
    .AddInMemoryIdentityResources(Config.IdentityResources)
    .AddInMemoryApiScopes(Config.ApiScopes)
    .AddInMemoryApiResources(Config.ApiResources)
    .AddInMemoryClients(Config.Clients)
    .AddTestUsers(TestUsers.Users)
    .AddLicenseSummary();

這邊在 IdentityServer 專案添加 Duende.IdentityServer.EntityFramework 函式庫與 EFCore PostgreSQL。

dotnet add package Duende.IdentityServer.EntityFramework
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore.Tools

接下來可以使用 Identity Server 預設提供的 DbContext, 依照功能性區分成兩個 DbContext 分別為 ConfigurationDbContext 與 PersistedGrantDbContext。

ConfigurationDbContext,這類型的資料通常是在後台經由管理員新增,變動頻率低讀取頻率高,包含以下 DBSet:

  • Client
  • ClientCorsOrigin
  • IdentityResource
  • ApiResource
  • ApiScope
  • IdentityProvider

PersistedGrantDbContext,這類型的資料會在系統運行中自動新增,變動頻率高,包含以下 DBSet:

  • PersistedGrant
  • DeviceFlowCodes
  • Key
  • ServerSideSession

接下來將回到 HostingExtensions.cs 將 AddInMemory 替換掉,這裡採用的是 Repository Pattern 將實際的存取邏輯分散到各個 Store 當中, 例如 Client 相關的操作有 ClientStore,IdentityResource 整合在 ResourceStore 當中,也就代表你如果不喜歡官方的存取方式, 可以透過依賴注入的方式直接替換掉官方實做。

不過在一般情況使用官方實做就很夠用了,這裡也不用一個一個 Store 註冊,可以直接使用 AddConfigurationStore 與 AddOperationalStore 方法, 一次將底層的 Store 註冊進來,上面也有提到 ConfigurationDbContext 的資料都是高頻率讀取的, 所以官方也加了 AddConfigurationStoreCache 背後採用 Decorator Pattern 在中間添加一層快取層。

下面是它們的原始碼,可以看到實際的運作原理。

public static IIdentityServerBuilder AddConfigurationStore<TContext>(
    this IIdentityServerBuilder builder,
    Action<ConfigurationStoreOptions>? storeOptionsAction = null)
    where TContext : DbContext, IConfigurationDbContext
{
    builder.Services.AddConfigurationDbContext<TContext>(storeOptionsAction);

    builder.AddClientStore<ClientStore>();
    builder.AddResourceStore<ResourceStore>();
    builder.AddCorsPolicyService<CorsPolicyService>();
    builder.AddIdentityProviderStore<IdentityProviderStore>();

    return builder;
}

public static IIdentityServerBuilder AddConfigurationStoreCache(
    this IIdentityServerBuilder builder)
{
    builder.AddInMemoryCaching();

    // add the caching decorators
    builder.AddClientStoreCache<ClientStore>();
    builder.AddResourceStoreCache<ResourceStore>();
    builder.AddCorsPolicyCache<CorsPolicyService>();
    builder.AddIdentityProviderStoreCache<IdentityProviderStore>();

    return builder;
}
    
public static IIdentityServerBuilder AddOperationalStore<TContext>(
    this IIdentityServerBuilder builder,
    Action<OperationalStoreOptions>? storeOptionsAction = null)
    where TContext : DbContext, IPersistedGrantDbContext
{
    builder.Services.AddOperationalDbContext<TContext>(storeOptionsAction);

    builder.AddSigningKeyStore<SigningKeyStore>();
    builder.AddPersistedGrantStore<PersistedGrantStore>();
    builder.AddDeviceFlowStore<DeviceFlowStore>();
    builder.AddServerSideSessionStore<ServerSideSessionStore>();
    builder.AddPushedAuthorizationRequestStore<PushedAuthorizationRequestStore>();

    builder.Services.AddSingleton<IHostedService, TokenCleanupHost>();

    return builder;
}

接下來將 AddInMemory 替換成 AddConfigurationStore 與 AddOperationalStore 方法,並且透過 MigrationsAssembly 將指定遷移腳本生成到自己的專案中。

要注意使用者資料並不包含在內,要將使用者資料持久化需要搭配 ASP.NET Core Identity。

var migrationsAssembly = typeof(Program).Assembly.GetName().Name;
const string connectionStringConfigure = @"User ID=postgres;Password=myPassw0rd;Host=localhost;Port=5432;Database=IdentityServerConfigure;Pooling=false;";
const string connectionStringOperational = @"User ID=postgres;Password=myPassw0rd;Host=localhost;Port=5432;Database=IdentityServerOperational;Pooling=false;";

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));
    })
    .AddTestUsers(TestUsers.Users)
    .AddLicenseSummary();

接下來分別幫這兩個 DbContext 建立遷移腳本與更新資料庫

dotnet ef migrations add InitialIdentityServerConfigurationDbMigration -c ConfigurationDbContext -o Migrations/ConfigurationDb
dotnet ef migrations add InitialIdentityServerPersistedGrantDbMigration -c PersistedGrantDbContext -o Migrations/PersistedGrantDb
dotnet ef database update -c ConfigurationDbContext
dotnet ef database update -c PersistedGrantDbContext

額外建立一個方法將初始化資料寫到資料庫內

private static void InitializeDatabase(IApplicationBuilder app)
{
    using (var serviceScope = app.ApplicationServices.GetService<IServiceScopeFactory>().CreateScope())
    {
        serviceScope.ServiceProvider.GetRequiredService<PersistedGrantDbContext>().Database.Migrate();

        var context = serviceScope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
        context.Database.Migrate();
        if (!context.Clients.Any())
        {
            foreach (var client in Config.Clients)
            {
                context.Clients.Add(client.ToEntity());
            }
            context.SaveChanges();
        }

        if (!context.IdentityResources.Any())
        {
            foreach (var resource in Config.IdentityResources)
            {
                context.IdentityResources.Add(resource.ToEntity());
            }
            context.SaveChanges();
        }

        if (!context.ApiScopes.Any())
        {
            foreach (var resource in Config.ApiScopes)
            {
                context.ApiScopes.Add(resource.ToEntity());
            }
            context.SaveChanges();
        }

        if (!context.ApiResources.Any())
        {
            foreach (var resource in Config.ApiResources)
            {
                context.ApiResources.Add(resource.ToEntity());
            }
            context.SaveChanges();
        }
    }
}

ConfigurePipeline 內部呼叫此方法

InitializeDatabase(app);

完成後測試 Identity Server 專案與 WebClient 專案可以正常登入就代表成功了。


Summary

今天成功將保存在記憶體的資料改成保存到資料庫內, 但想要在後台頁面建立與管理 Client 或 ApiScope 的話, Identity Server 並沒有提供,必須要搭配其他開源的 admin 專案, 才可以在頁面上管理這些資源。