ASP.NET Core Identity で主キーに値オブジェクトを使う方法

はじめに

ドメイン駆動設計のパターンの一つである値オブジェクトを実践するようになってから、ASP.NET Core Identity でユーザーの ID の型 が string なことに恐怖を感じるようになった。ユーザー ID を表す値オブジェクトじゃないと安心してコード書けない。

そういうわけで、ASP.NET Core Identity を使うときはユーザー ID の型に値オブジェクトを使うようにしているので、その手順をメモしておく。

ユーザー ID を表す値オブジェクトを構造体で作成

ASP.NET Core Identity の ID の型として使うためには、IEquatable<T> を実装する必要がある。

#nullable enable
using System;
using System.Diagnostics.CodeAnalysis;

namespace HelloIdentity.Models
{
    public readonly struct UserId : IEquatable<UserId>
    {
        readonly string value;

        public UserId(string value) =>
            this.value = value;

        public static UserId NewUserId() =>
            new UserId(Guid.NewGuid().ToString());

        public override string ToString() => value;

        public override int GetHashCode() =>
            value.GetHashCode();

        public override bool Equals(object? obj)
        {
            if (obj is UserId other)
            {
                return value == other.value;
            }
            return false;
        }

        public bool Equals([AllowNull] UserId other) =>
            value == other.value;

        public static bool operator ==(UserId left, UserId right) =>
            left.Equals(right);

        public static bool operator !=(UserId left, UserId right) =>
            !(left == right);
    }
}

IdentityDbContext から派生して ApplicationDbContext を作成

TKey には先ほど作成した UserId を指定する。データベースには文字列として保存するので、HasConversion を使って UserIdstring 相互に変換するように指示。

#nullable enable
using HelloIdentity.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace HelloIdentity.Data
{
    public class ApplicationDbContext : IdentityDbContext<IdentityUser<UserId>, IdentityRole<UserId>, UserId>
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }

        protected override void OnModelCreating(ModelBuilder builder)
        {
            base.OnModelCreating(builder);

            builder.Entity<IdentityUser<UserId>>(b =>
            {
                b.Property(x => x.Id)
                    .HasConversion(x => x.ToString(), x => new UserId(x));
            });

            builder.Entity<IdentityRole<UserId>>(b =>
            {
                b.Property(x => x.Id)
                    .HasConversion(x => x.ToString(), x => new UserId(x));
            });
        }
    }
}

UserStore から派生した ApplicationUserStore を作成

UserStore はそのままだと内部で stringUserId に変換できず、実行時に例外が発生してしまう。 ConvertIdFromString をオーバーライドして、UserId に変換する処理を記述しておく。 あとついでに、新規登録時に新しい UserId を割り当てるようにもしておく。

#nullable enable
using System.Threading;
using System.Threading.Tasks;
using HelloIdentity.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;

namespace HelloIdentity.Models
{
    public class ApplicationUserStore : UserStore<IdentityUser<UserId>, IdentityRole<UserId>, ApplicationDbContext, UserId>
    {
        public ApplicationUserStore(ApplicationDbContext context, IdentityErrorDescriber? describer = null)
            : base(context, describer)
        {
        }

        public override UserId ConvertIdFromString(string id) =>
            new UserId(id);

        public override Task<IdentityResult> CreateAsync(IdentityUser<UserId> user, CancellationToken cancellationToken = default)
        {
            user.Id = UserId.NewUserId();
            return base.CreateAsync(user, cancellationToken);
        }
    }
}

Startup で ASP.NET Core Identity を構成

AddIdentity でユーザーとロールの型を登録する際に、TKeyUserId を指定する。AddUserStoreApplicationUserStore も忘れずに登録。

#nullable enable
using HelloIdentity.Data;
using HelloIdentity.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace HelloIdentity
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(
                    Configuration.GetConnectionString("DefaultConnection")));

            services
                .AddIdentity<IdentityUser<UserId>, IdentityRole<UserId>>(
                    options => options.SignIn.RequireConfirmedAccount = false)
                .AddEntityFrameworkStores<ApplicationDbContext>()
                .AddUserStore<ApplicationUserStore>()
                .AddDefaultUI()
                .AddDefaultTokenProviders();

            services.AddControllersWithViews();
            services.AddRazorPages();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                app.UseDatabaseErrorPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
                app.UseHsts();
            }
            app.UseHttpsRedirection();
            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthentication();
            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllerRoute(
                    name: "default",
                    pattern: "{controller=Home}/{action=Index}/{id?}");
                endpoints.MapRazorPages();
            });
        }
    }
}

ASP.NET Core Identity の Default UI 修正

_LoginPartial.cshtmlSignInManager<IdentityUser>UserManager<IdentityUser> を記述している箇所があるので、SignInManager<IdentityUser<UserId>>UserManager<IdentityUser<UserId>> に書き換える。

@using Microsoft.AspNetCore.Identity
@inject SignInManager<IdentityUser<UserId>> SignInManager
@inject UserManager<IdentityUser<UserId>> UserManager

<ul class="navbar-nav">
@if (SignInManager.IsSignedIn(User))
{
    <li class="nav-item">
        <a  class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Manage/Index" title="Manage">Hello @User.Identity.Name!</a>
    </li>
    <li class="nav-item">
        <form  class="form-inline" asp-area="Identity" asp-page="/Account/Logout" asp-route-returnUrl="@Url.Action("Index", "Home", new { area = "" })">
            <button  type="submit" class="nav-link btn btn-link text-dark">Logout</button>
        </form>
    </li>
}
else
{
    <li class="nav-item">
        <a class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Register">Register</a>
    </li>
    <li class="nav-item">
        <a class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Login">Login</a>
    </li>
}
</ul>

ASP.NET Core Identity でユーザー ID に値オブジェクトを使うための作業は以上。

おわりに

ASP.NET Core Identity のユーザー ID に string ではなく値オブジェクトを使えるようになったので、 安心してコードが書けるようになった。めでたい。

IdentityRole<TKey>TKeyUserId になってしまうのが玉に瑕だけど、今のところ ID よりも名前を指定して取得することの方が多いので許容範囲内かと。