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 分別為 ConfigurationDbContextPersistedGrantDbContext

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 註冊,可以直接使用 AddConfigurationStoreAddOperationalStore 方法, 一次將底層的 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 替換成 AddConfigurationStoreAddOperationalStore 方法,並且透過 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 專案, 才可以在頁面上管理這些資源。