しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

IClaimsTransformation を使って ASP.NET Core のユーザーに独自のクレームを追加する

ASP.NET Core で認証を使っていて、標準のクレーム以外を User.Identity に持たせたくなったので実現する方法を調べました。ASP.NET 時代では Global.asax でイベントハンドラーを追加して Context.User を入れ替えて実現してましたが、ASP.NET Core にはそんなものはありません。

良い感じに認証クッキーからクレームを復元するタイミングで、何らかの処理を挟めないか調べてみると IClaimsTransformation というインターフェースが見つかりました。

このインターフェースは TransformAsync というメソッドのみ持っているシンプルなものです。

もはや説明は不要な気がしますが、このメソッドの実装でいい感じに ClaimsPrincipal に対してクレームの追加を行った後に返せば、コントローラやビューなどで追加したクレームを参照できるようになります。

デフォルトでは NoopClaimsTransformation が組み込まれていて、実装はそのまま渡された ClaimsPrincipal を返しているだけです。とりあえず独自の変換を実装する前に動きを確認しておきます。

public class MyClaimsTransformation : IClaimsTransformation
{
    public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        return Task.FromResult(principal);
    }
}

適当に渡された ClaimsPrincipal をそのまま返す実装を用意しました。

実際に ASP.NET Core の認証処理に組むこむためには DI を使って、実装したクラスを登録する必要があるのはこれまで通りです。ASP.NET Core はいろんな処理を DI で差し替えることが出来ますが、柔軟すぎて逆にカスタマイズが難しい部分もあります。

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

        services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
            .AddEntityFrameworkStores<ApplicationDbContext>();

        // IClaimsTransformation を継承したクラスを DI に追加
        services.AddScoped<IClaimsTransformation, MyClaimsTransformation>();

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

DI に追加してしまえば設定は完了なので、動かしてみて変換処理が呼ばれるかを確認しておきました。

未ログイン状態の挙動が気になりましたが、ログインしていないときには変換処理は呼ばれませんでした。なので TransformAsync はログインされている前提のコードで良さそうです。

f:id:shiba-yan:20200104172420p:plain

変換メソッドは Task を返すようになっているのと、DI を使って他のインスタンスを取ることができるので、内部で DB へのアクセス処理なども簡単に書けて便利です。

サンプルとして登録されているメールアドレスと Gravatar の URL をクレームに追加する変換処理を実装してみました。Gravatar の URL はメールアドレスの MD5 を計算するだけなので簡単です。

今回実装したクレーム変換の処理は以下のようになります。コンストラクタで ASP.NET Core Identity 向けの DbContext 実装を受け取って、TransformAsync で DB にクエリを投げてメールアドレスを取得するようにしています。特に難しいことはしていないです。

DI と async / await が使えるので、無理のない自然なコードで書けて最高です。

public class MyClaimsTransformation : IClaimsTransformation
{
    public MyClaimsTransformation(ApplicationDbContext applicationDbContext)
    {
        _applicationDbContext = applicationDbContext;
    }

    private readonly ApplicationDbContext _applicationDbContext;

    public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        var identity = (ClaimsIdentity)principal.Identity;

        var user = await _applicationDbContext.Users.FindAsync(principal.FindFirstValue(ClaimTypes.NameIdentifier));

        identity.AddClaim(new Claim(ClaimTypes.Email, user.Email));
        identity.AddClaim(new Claim("AvatarImage", GenerateGravatarUrl(user.Email)));

        return principal;
    }

    private string GenerateGravatarUrl(string email)
    {
        using var md5 = MD5.Create();

        var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(email.ToLower()));

        var hashString = new StringBuilder();

        for (int i = 0; i < hash.Length; i++)
        {
            hashString.Append(hash[i].ToString("x2"));
        }

        return $"https://www.gravatar.com/avatar/{hashString}";
    }
}

ASP.NET Core Identity には DB にクレームを永続化する機能もありますが、今回の IClaimsTransformation を使った方法ではデータソースに任意のものを利用出来るので簡単です。微妙に Identity 周りはブラックボックスになってるので、クレームの追加も面倒な感じです。

適当なページにログインしているユーザーのクレーム一覧を出すようにして確認してみました。

f:id:shiba-yan:20200104180043p:plain

ちゃんと追加したメールアドレスと Gravatar の URL がクレームに含まれていることが確認できますね。クレームに追加することで、いろんな部分で HttpContext.User 経由して値が利用できるようになります。

以下のようにビューに Gravatar のアイコンを表示する定義も簡単に書けます。

@if (SignInManager.IsSignedIn(User))
{
  <li class="nav-item">
    <img src="@(User.FindFirstValue("AvatarImage"))" height="42" />
  </li>
  <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>
}

実行してみると、ユーザー名の隣に Gravatar で登録したアイコンが表示されました。

f:id:shiba-yan:20200104180541p:plain

実際にアプリケーションを書いていると、標準のクレームでは足りなくて都度 ViewBag に入れたり、JavaScript で取ってきたりと苦労が色々とありましたが、シンプルにクレームに入れてしまえば ASP.NET Core 全体で使えるようになるので非常に便利です。

クレームを ClaimsPrincipal から取るのが少し面倒ですが、拡張メソッドを用意してあげれば間違えることもありません。これで実際にかなり見通しの良いコードになりました。