ASP.NET Core Blazor Server でオレオレ認証を追加したい without Cookie
過去記事で色々認証系について書いてきました。
ここで書いてきたものは、なるべくクラウドの IdP に依存せずに手軽に独自の認証を追加する方法でした。
これまでの方法の共通点は認証クッキーを作る部分は ASP.NET Core Blazor Server の外側の Razor Pages や MVC で作る方法でした。
この方法が一番、提供されている部品を使いつつ独自のログイン機能を実装できる方法なのですが Blazor Server のページ以外を経由する必要があるので、その点は若干めんどくさいです。どうせなら Blazor の世界の中で全部やりたいなぁ…というのが今回の主題です。
やりかた
Blazor Server の中で独自ログイン画面を作って、認証処理をやってブラウザーの localStorage や sessionStorage やメモリ上に認証しているかどうかのフラグを持つ方法で今は、やっていこうと思います。localStorage に保存するようにすれば、同じブラウザーであれば一度ブラウザーを閉じた後でも有効ですし、sessionStorage であればブラウザーを閉じたら終わりにできます。
メモリ上だと、どの範囲で保持するかにもよりますが恐らくは DI コンテナで Scoped で管理することになると思うので画面をリフレッシュしただけでログインが切れてしまいます。
用途に応じてお好みのところに保存しましょう。
では早速実装していきます。ASP.NET Core Blazor Server のプロジェクトを作って 1 から実装していきます。
前にやったのと同じようにログインページに画面遷移するだけのコンポーネントを作って App.razor に認証系のコンポーネントを置いていきます。
@inject NavigationManager NavigationManager
@code {
protected override void OnInitialized()
{
NavigationManager.NavigateTo("/");
}
}
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
そして Pages/Counter.razor と Pages/FetchData.razor に Authrize 属性をつけてログインしないと表示できないようにしておきます。
@page "/fetchdata"
@attribute [Authorize]
// 以下略
@page "/counter"
@attribute [Authorize]
// 以下略
そして Shared/NavMenu.razor に AuthorizeView コンポーネントを追加して Counter と Fetch data のリンクをログインしていないと表示しないようにします。
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink>
</div>
<AuthorizeView>
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="oi oi-plus" aria-hidden="true"></span> Counter
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="fetchdata">
<span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
</NavLink>
</div>
</AuthorizeView>
</nav>
</div>
この状態で実行すると Index.razor しか表示されなくなります。画面遷移のためのメニューもでなくて、アドレス直指定してもトップページに戻されます。
ここにログイン系機能を追加していきます。以下のドキュメントにもある通り AuthenticationStateProvider というクラスが Blazor の各種コンポーネントが利用する認証情報の元ネタになります。
ここを拡張することで、任意の場所から認証情報を取得するようにすることが出来ます。とりあえず一番お手軽なメモリ上に保持するような形でやってみましょう。
AuthenticationStateProvider を継承した CustomAuthenticationStateProvider クラスを作成します。
using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Claims;
namespace BlazorOreoreAuth.Auth;
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
private static readonly AuthenticationState UnauthorizedAuthenticationState = new AuthenticationState(new ClaimsPrincipal());
private ClaimsPrincipal? _principal;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
{
if (_principal is null) return Task.FromResult(UnauthorizedAuthenticationState);
return Task.FromResult(new AuthenticationState(_principal));
}
// これ呼んでサインインしてもらう
public Task UpdateSignInStatusAsync(ClaimsPrincipal? principal)
{
_principal = principal;
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
return Task.CompletedTask;
}
}
そして Program.cs で、先ほど作ったサービスの登録を行います。
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(p => p.GetRequiredService<CustomAuthenticationStateProvider>());
そして Index.razor にログイン処理を書きます。
@page "/"
@using BlazorOreoreAuth.Auth
@using System.Security.Claims
@inject CustomAuthenticationStateProvider _authProvider
<PageTitle>Index</PageTitle>
<AuthorizeView>
<Authorized>
<h1>ようこそ @context.User.Identity?.Name さん</h1>
<button @onclick="SignOut">ログアウト</button>
</Authorized>
<NotAuthorized>
<h1>ログイン画面</h1>
<input @bind="_userName" />
<button @onclick="SignIn">ログイン</button>
</NotAuthorized>
</AuthorizeView>
@code {
private string? _userName;
private async Task SignIn()
{
if (string.IsNullOrWhiteSpace(_userName)) return;
// とりあえず素直に入力された名前でログインする
await _authProvider.UpdateSignInStatusAsync(new ClaimsPrincipal(
new ClaimsIdentity(
new Claim[]
{
new (ClaimTypes.Name, _userName),
},
"Custom"
)
));
}
private async Task SignOut()
{
await _authProvider.UpdateSignInStatusAsync(null);
}
}
これでブラウザーリフレッシュで消えてしまうログイン機能が出来上がりました。以下のように動きます。
ばっちりですね。今回は作成した ClaimsPrincipal に名前しか設定していませんが Role とかも追加すればロールベースの承認とかも出来ます。必要に応じて追加するといいと思います。
まとめ
ということで Blazor に閉じた世界内でログインを実装してみました。
その場合でも AuthenticationStateProvider を拡張することで、ログイン後の各種承認系処理は既存の ASP.NET Core Blazor で持っている仕組み (Authrize 属性の利用など) が使える様にすることが出来ます。
なるべく独自実装部分は少なくてすむようにしたほうが認証系は何かと安心です。
また、今回はメモリ上に ClaimsPrincipal を保持しているだけですが、ここの保存先をブラウザーのストレージにすることで画面リフレッシュくらいでは消えてしまわないようにログイン情報を保持することが出来るようになります。
実際に sessionStorage に保存するように変更したコードを GitHub に上げています。
sessionStorage にすることで大きく変わったのは、App.razor の OnAfterRenderAsync でストレージからの認証情報読み込み処理を追加しているところです。ストレージの読み込みは JavaScript との連携機能が裏で呼び出されているのですが、このタイミングが Blazor Server では利用可能になる最初のタイミングなので、そこで行っています。これより前にやると例外になる点が注意点です。
Discussion
.Net8でのBlazzorServerで実装すると、
Routes.razorに
入れることで動作しました