ASP.NET Core Blazor Server でクレームベースの承認とポリシーベースの承認をする
先日、ログイン機能を付けた Blazor Server アプリを試してみました。
これは純粋にログインしている・していないで表示を分けたりすることと、ロールベースの承認を動かしてみました。今日は、これに加えてクレームベースとポリシーベースの承認をためしてみたいと思います。
といっても先日の記事の内容までトレースしていたらすぐできる内容になります。
クレームベースの承認
以下のページが公式のクレームベースの承認のドキュメントになります。基本的にこれを組み込んでいく形になります。
組み込む前にログイン処理を実装している Areas/MyLogin/Pages/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
で以下のように定義します。
builder.Services.AddAuthorization(options =>
{
// EmployeeNumber が 0001 か 0011 の人のみ通すポリシー
options.AddPolicy("EmployeeNumberIs0001Or0011", builder =>
{
builder.RequireClaim("EmployeeNumber", "0001", "0011");
});
});
上のコードにあるように RequireClaim
で特定の名前のクレームの有無と値の完全一致でのポリシーの定義が出来ます。後は FetchData.razor
の先頭らへんに @attribute [Authorize(Policy = "EmployeeNumberIs0001Or0011")]
を追加すればページの表示・非表示の設定は完了です。
@page "/fetchdata"
@attribute [Authorize(Policy = "EmployeeNumberIs0001Or0011")]
<PageTitle>Weather forecast</PageTitle>
@* 以下省略 *@
これで Admin か Kazuki で入った時以外は FetchData.razor
のページは表示できなくなります。
実際に動かすと以下のようになります。権限のないユーザーで FetchData.razor
を開くと App.razor
で NotAuthorized
時にログインページに遷移するように指定しているためログインページに移動します。
ポリシーベースの承認
次にポリシーベースの承認をします。これが一番自由度が高くてなんでもできます。
ロールベース → クレームベース → ポリシーベースの順に要件を満たせるか確認していく形でやるのがいいと思います。
ポリシーベースの承認は以下のページに記載があります。
ポリシーの実装方法はいくつかやり方がありますが、一番自由度が高いのは IAuthorizationHandler
インターフェースを実装するか AuthorizationHandler<T>
を継承する形で実装する形になります。
では EmployeeNumber の 3 桁目が 1 になっている従業員番号のみ許可するようなポリシーを作ってみようと思います。
AuthorizationHandler
を実装する際には IAuthorizationRequirement
という何もメンバーを持たないマーカーインターフェースを実装したクラスを作って、それを型引数に指定して AuthorizationHandler<T>
を実装する形が一番やりやすいです。IAuthorizationRequirement
の実装クラスには、承認処理の中で使いたいパラメーターなどをプロパティとして定義することが出来ます。これを使って承認処理にカスタマイズの余地を与えることが出来ます。今回の承認ロジックは 3 桁目が 1 であることといったものですが、これを任意の桁の値が 1 のようにしたい場合は IAuthorizationRequirement
の実装クラスのプロパティあたりに、そういった値を設定できるものを追加すると良さそうです。
とりあえず作ってみましょう。まずは、Requirement を作ります。今回はただの目印として使うだけなので中身は空です。
using Microsoft.AspNetCore.Authorization;
namespace AuthBlazorServerApp.Auth;
public class TestRequirement : IAuthorizationRequirement
{
}
そして AuthorizationHandler<T>
を継承して HandleRequirementAsync
メソッドで承認ロジックを書いていきます。成功した場合は context.Success(requirement);
を呼んで成功したことを表すことが出来ます。Success
を呼ばなくても別のハンドラが Success
を呼び出していれば承認されます。Fail
メソッドを呼んで絶対失敗させるようにすることもできます。
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 コンテナに登録します。
// カスタムのハンドラー!
builder.Services.AddSingleton<IAuthorizationHandler, TestAuthHandler>(); // Scoped でも Transient でも可
そして AddAuthrization
でポリシーを登録します。ここで先程作った TestRequirement
を指定することで TestAuthHandler
の HandleRequirementAsync
が呼ばれるようになります。以下のコードは Test という名前のポリシーで TestAuthHandler
が呼ばれるようにしています。
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.razor
の Authorize
属性のポリシー名を Test に書き換えましょう。
@page "/fetchdata"
@attribute [Authorize(Policy = "Test")]
<PageTitle>Weather forecast</PageTitle>
@* 以下略 *@
こうすると以下のように FetchData.razor
は Shnji か Kazuaki でサインインした場合のみ表示できるようになります。
もうちょっと簡易的に任意のラムダ式を渡して検証したりも出来ますが、その方法は上で示したドキュメントを参照してください。なんとなくラムダ式でサクッと出来るような要件ならロールベースや、クレームベースでもいけるようなケースが多いと思います。
ポリシーベースの強力なところ
ポリシーベースの AuthorizationHandler
の実装クラスはコンストラクタ インジェクションで任意のサービスを受け取るように構成することが出来ます。
例えばやろうと思えば毎回毎回 DB や Web API を呼び出してといったことも処理の中で出来ます。重いのでやらない方が絶対いいと思いますが。やるにしてもキャッシュするなり工夫が必要そうですけど、まぁ何でもできるというのは強みですね。
ルート情報も加味して承認ロジック書きたい
App.razor
の AuthorizeRouteView
には Resource
プロパティがあって、ここに @routeData
を渡すことで AuthorizationHandler
から RouteData
を参照することが出来るようになります。
ためしてみましょう。App.razor
の AuthorizationRouteView
の行を以下のように書き換えます。
<AuthorizeRouteView Resource="@routeData" RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
そうすると AuthorizationHandler
のメソッドの context
引数の Resource
プロパティに RouteData
がわたってきていることが確認できます。
あとは、やりたい承認ロジックにあわせて色々やる感じです。
コードで確認したい
今までは宣言的に属性でポリシーで許可されているユーザーかどうか見てきましたが、コード内でも確認したくなると思います。
試しに FetchData.razor
で忖度をして Test ポリシーのユーザーが来た場合は全部のデータの Summary を Warm にしてあげましょう。ユーザーの情報を使いたいので WeatherForecastService
の引数で ClaimsPrincipal
を受け取るように変更します。そして IAuthorizationService
をコンストラクタで受け取るようにします。
この IAuthorizationService
の AuthorizeAsync
メソッドを使ってユーザーがポリシーを満たすユーザーかどうか確認できます。
コードは以下のようになります。
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
に渡すようにします。
@* 上のマークアップ部分は省略 *@
@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 のデータが返って来るようになりました。
IAuthorizationService
の AuthorizeAsync
メソッドには 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 とかを使うときはログイン画面とかがいらなくなるぶんもっと楽になると思います。
この記事を書きながら書いていたコードは以下のリポジトリにあります。
Discussion