ASP.NET Core Blazor Server でクレームベースの承認とポリシーベースの承認をする


先日、ログイン機能を付けた Blazor Server アプリを試してみました。

https://zenn.dev/okazuki/articles/add-auth-to-blazor-server-app

これは純粋にログインしている・していないで表示を分けたりすることと、ロールベースの承認を動かしてみました。今日は、これに加えてクレームベースとポリシーベースの承認をためしてみたいと思います。
といっても先日の記事の内容までトレースしていたらすぐできる内容になります。

クレームベースの承認

以下のページが公式のクレームベースの承認のドキュメントになります。基本的にこれを組み込んでいく形になります。

https://docs.microsoft.com/ja-jp/aspnet/core/security/authorization/claims?view=aspnetcore-6.0

組み込む前にログイン処理を実装している Areas/MyLogin/Pages/Index.cshtml.cs のコードを以下のようにして、適当にユーザー名でクレームが変わるようにしておきました。

Index.cshtml.cs
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace AuthBlazorServerApp.Areas.MySignin.Pages;

public class IndexModel : PageModel
{
    [BindProperty]
    [Required]
    public string? UserName { get; set; }
    public async Task<IActionResult> OnPost()
    {
        if (ModelState.IsValid is false) return Page();

        var userName = UserName!;
        var principal = new ClaimsPrincipal(new ClaimsIdentity(
            CreateClaims(userName),
            CookieAuthenticationDefaults.AuthenticationScheme));
        await HttpContext.SignInAsync(
            CookieAuthenticationDefaults.AuthenticationScheme,
            principal);
        return Redirect("/");
    }

    private IEnumerable<Claim> CreateClaims(string userName)
    {
        yield return new Claim(ClaimTypes.Name, userName);
        yield return new Claim(ClaimTypes.Role, "User");
        if (userName == "Admin")
        {
            yield return new Claim(ClaimTypes.Role, "Administrator");
        }

        yield return userName switch
        {
            "Admin" => new Claim("EmployeeNumber", "0001"),
            "Kazuki" => new Claim("EmployeeNumber", "0011"),
            "Shinji" => new Claim("EmployeeNumber", "0111"),
            "Kazuaki" => new Claim("EmployeeNumber", "1111"),
            _ => new Claim("EmployeeNumber", "9999"),
        };
    }
}

では、クレームベースの認証で以下のような制御をしてみたいと思います。
EmployeeNumber が 0001, 0011 の人のみ FetchData.razor を表示できるようにします。まずは EmployeeNumber が 0001, 0011 の人のみ通すポリシーを Program.cs で以下のように定義します。

Program.cs
builder.Services.AddAuthorization(options =>
{
    // EmployeeNumber が 0001 か 0011 の人のみ通すポリシー
    options.AddPolicy("EmployeeNumberIs0001Or0011", builder =>
    {
        builder.RequireClaim("EmployeeNumber", "0001", "0011");
    });
});

上のコードにあるように RequireClaim で特定の名前のクレームの有無と値の完全一致でのポリシーの定義が出来ます。後は FetchData.razor の先頭らへんに @attribute [Authorize(Policy = "EmployeeNumberIs0001Or0011")] を追加すればページの表示・非表示の設定は完了です。

FetchData.razor
@page "/fetchdata"
@attribute [Authorize(Policy = "EmployeeNumberIs0001Or0011")]

<PageTitle>Weather forecast</PageTitle>
@* 以下省略 *@

これで Admin か Kazuki で入った時以外は FetchData.razor のページは表示できなくなります。
実際に動かすと以下のようになります。権限のないユーザーで FetchData.razor を開くと App.razorNotAuthorized 時にログインページに遷移するように指定しているためログインページに移動します。

ポリシーベースの承認

次にポリシーベースの承認をします。これが一番自由度が高くてなんでもできます。
ロールベース → クレームベース → ポリシーベースの順に要件を満たせるか確認していく形でやるのがいいと思います。

ポリシーベースの承認は以下のページに記載があります。

https://docs.microsoft.com/ja-jp/aspnet/core/security/authorization/policies?view=aspnetcore-6.0

ポリシーの実装方法はいくつかやり方がありますが、一番自由度が高いのは IAuthorizationHandler インターフェースを実装するか AuthorizationHandler<T> を継承する形で実装する形になります。
では EmployeeNumber の 3 桁目が 1 になっている従業員番号のみ許可するようなポリシーを作ってみようと思います。

AuthorizationHandler を実装する際には IAuthorizationRequirement という何もメンバーを持たないマーカーインターフェースを実装したクラスを作って、それを型引数に指定して AuthorizationHandler<T> を実装する形が一番やりやすいです。IAuthorizationRequirement の実装クラスには、承認処理の中で使いたいパラメーターなどをプロパティとして定義することが出来ます。これを使って承認処理にカスタマイズの余地を与えることが出来ます。今回の承認ロジックは 3 桁目が 1 であることといったものですが、これを任意の桁の値が 1 のようにしたい場合は IAuthorizationRequirement の実装クラスのプロパティあたりに、そういった値を設定できるものを追加すると良さそうです。

とりあえず作ってみましょう。まずは、Requirement を作ります。今回はただの目印として使うだけなので中身は空です。

TestRequirement.cs
using Microsoft.AspNetCore.Authorization;

namespace AuthBlazorServerApp.Auth;

public class TestRequirement : IAuthorizationRequirement
{
}

そして AuthorizationHandler<T> を継承して HandleRequirementAsync メソッドで承認ロジックを書いていきます。成功した場合は context.Success(requirement); を呼んで成功したことを表すことが出来ます。Success を呼ばなくても別のハンドラが Success を呼び出していれば承認されます。Fail メソッドを呼んで絶対失敗させるようにすることもできます。

TestAuthHandler.cs
using Microsoft.AspNetCore.Authorization;

namespace AuthBlazorServerApp.Auth;

public class TestAuthHandler : AuthorizationHandler<TestRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, TestRequirement requirement)
    {
        // EmployeeNumber の Claim があって
        var employeeNumberClaim = context.User.Claims.FirstOrDefault(x => x.Type == "EmployeeNumber");
        if (employeeNumberClaim is null) return Task.CompletedTask;

        // 右から 3 桁目が 1 だったら OK (EmployeeNumber は 4 桁想定なので index = 1 が 3 桁目)
        if (employeeNumberClaim.Value.Length == 4 && employeeNumberClaim.Value[1] == '1')
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

ハンドラーを作ったら Program.cs で DI コンテナに登録します。

Program.cs
// カスタムのハンドラー!
builder.Services.AddSingleton<IAuthorizationHandler, TestAuthHandler>(); // Scoped でも Transient でも可

そして AddAuthrization でポリシーを登録します。ここで先程作った TestRequirement を指定することで TestAuthHandlerHandleRequirementAsync が呼ばれるようになります。以下のコードは Test という名前のポリシーで TestAuthHandler が呼ばれるようにしています。

Program.cs
builder.Services.AddAuthorization(options =>
{
    // EmployeeNumber が 0001 か 0011 の人のみ通すポリシー
    options.AddPolicy("EmployeeNumberIs0001Or0011", builder =>
    {
        builder.RequireClaim("EmployeeNumber", "0001", "0011");
    });

    // Test という名前のポリシーを登録
    options.AddPolicy("Test", builder =>
    {
        // ここで IAuthorizationRequirement を実装したクラスを設定する。
        builder.AddRequirements(new TestRequirement());
    });

    // デフォルトで認証されたユーザーが必要
    options.FallbackPolicy = options.DefaultPolicy;
});

では FetchData.razorAuthorize 属性のポリシー名を Test に書き換えましょう。

FetchData.razor
@page "/fetchdata"
@attribute [Authorize(Policy = "Test")]

<PageTitle>Weather forecast</PageTitle>

@* 以下略 *@

こうすると以下のように FetchData.razor は Shnji か Kazuaki でサインインした場合のみ表示できるようになります。

もうちょっと簡易的に任意のラムダ式を渡して検証したりも出来ますが、その方法は上で示したドキュメントを参照してください。なんとなくラムダ式でサクッと出来るような要件ならロールベースや、クレームベースでもいけるようなケースが多いと思います。

ポリシーベースの強力なところ

ポリシーベースの AuthorizationHandler の実装クラスはコンストラクタ インジェクションで任意のサービスを受け取るように構成することが出来ます。
例えばやろうと思えば毎回毎回 DB や Web API を呼び出してといったことも処理の中で出来ます。重いのでやらない方が絶対いいと思いますが。やるにしてもキャッシュするなり工夫が必要そうですけど、まぁ何でもできるというのは強みですね。

ルート情報も加味して承認ロジック書きたい

App.razorAuthorizeRouteView には Resource プロパティがあって、ここに @routeData を渡すことで AuthorizationHandler から RouteData を参照することが出来るようになります。

ためしてみましょう。App.razorAuthorizationRouteView の行を以下のように書き換えます。

App.razor
<AuthorizeRouteView Resource="@routeData" RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">

そうすると AuthorizationHandler のメソッドの context 引数の Resource プロパティに RouteData がわたってきていることが確認できます。

あとは、やりたい承認ロジックにあわせて色々やる感じです。

コードで確認したい

今までは宣言的に属性でポリシーで許可されているユーザーかどうか見てきましたが、コード内でも確認したくなると思います。

試しに FetchData.razor で忖度をして Test ポリシーのユーザーが来た場合は全部のデータの Summary を Warm にしてあげましょう。ユーザーの情報を使いたいので WeatherForecastService の引数で ClaimsPrincipal を受け取るように変更します。そして IAuthorizationService をコンストラクタで受け取るようにします。

この IAuthorizationServiceAuthorizeAsync メソッドを使ってユーザーがポリシーを満たすユーザーかどうか確認できます。

コードは以下のようになります。

WeatherForecastService.cs
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;

namespace AuthBlazorServerApp.Data;

public class WeatherForecastService
{
    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };
    private readonly IAuthorizationService _authorizationService;

    public WeatherForecastService(IAuthorizationService authorizationService)
    {
        _authorizationService = authorizationService;
    }

    public async Task<WeatherForecast[]> GetForecastAsync(DateTime startDate, ClaimsPrincipal user)
    {
        // ユーザーが Test ポリシーを満たしているかどうか
        var result = await _authorizationService.AuthorizeAsync(user, "Test");
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = startDate.AddDays(index),
            TemperatureC = Random.Shared.Next(-20, 55),
            // Test ポリシーを満たしているなら全データを Warm にする
            Summary = result.Succeeded ? "Warm" : Summaries[Random.Shared.Next(Summaries.Length)]
        }).ToArray();
    }
}

呼び出し側の FetchData.razor も以下のようにユーザーのデータをとってきて WeatherForecastService に渡すようにします。

FetchData.razor
@* 上のマークアップ部分は省略 *@

@code {
    [CascadingParameter]
    private Task<AuthenticationState> AuthenticationState { get; set; } = null!;

    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        var authState = await AuthenticationState;
        forecasts = await ForecastService.GetForecastAsync(DateTime.Now, authState.User);
    }
}

実行すると以下のように忖度が働いて Test ポリシーを満たすユーザーの場合は常に Warm のデータが返って来るようになりました。

IAuthorizationServiceAuthorizeAsync メソッドには AuthorizationPolicy を受け取るオーバーロードもあります。これを使うと以下のようにポリシー名ではなく IAuthorizationRequirement の実装クラスを指定してチェックをすることもできます。

var result = await _authorizationService.AuthorizeAsync(user, new AuthorizationPolicy(
    new[] { new TestRequirement() }, // requirement
    Enumerable.Empty<string>())); // aithenticationSchemas

まとめ

ということで 2 買いにわけて ASP.NET Core Blazor Server でオレオレのログイン機能の実装方法と、それに対して承認を行う方法を見てきました。

オレオレのログイン機能だといっても HttpContext の User に ClaimsPrincipal が設定されるようにしてしまえば、そのあとは組み込みの色々な便利な機能が使えることがわかりました。

ちゃんとした IdP とかを使うときはログイン画面とかがいらなくなるぶんもっと楽になると思います。

この記事を書きながら書いていたコードは以下のリポジトリにあります。

https://github.com/runceel/AuthBlazorServerApp