🐕

ASP.NET Core Blazor Server に 1 からオレオレ認証機能を付けてみよう

2022/03/28に公開
1

先日、ASP.NET Core Blazor Server にオレオレ ログインを追加してみましたが、これは認証機能をもったテンプレートに手を入れてる形でやりました。今回は認証機能を持ってないテンプレートに足していく形でやってみようと思います。

そのまま前の記事のクローンをつくるのはつまらないので、業務アプリにありがちな以下のような形で作っていこうと思います。

  • ログインしていないユーザーが来たら、まずはログインページに行く
  • ログインは外部の IdP ではなく自分たち(もしくは何処かの DB などの情報をもとに認証する
  • ログインユーザーの権限で、ページにアクセス出来る・出来ないが変わる
  • 処理によってはログインユーザーの権限を見て処理をわける

プロジェクトの作成と初期設定

では、Visual Studio 2022 で Blazro サーバーのプロジェクトを認証無しで作ります。
そして、Program.cs に認証系の設定を追加していきます。

Program.cs
using AuthBlazorServerApp.Data;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddSingleton<WeatherForecastService>();

// 認証系のサービスを追加
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
    {
        // 独自のログインページの URL
        options.LoginPath = "/MyLogin";
    });

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/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.MapBlazorHub();
app.MapFallbackToPage("/_Host");

app.Run();

そして App.razor でルーターなどが設定されている箇所に認証情報のサポートを追加していきます。変更前は以下のような状態です。

App.razor
<Router AppAssembly="@typeof(App).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
        <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>

これに認証情報をカスケードパラメーターで渡していく設定や未ログイン時にログインページにリダイレクトする処理を追加します。まずは、ログインページにリダイレクトするだけのコンポーネントを作ります。Shared/RedirectToLogin.razor を作って以下のように必ずカスタムのログインページの /MyLogin に行くようにします。
もう少し親切につくるなら、ログインしていませんというエラーメッセージを表示してログインページへのリンクを置くような画面にしてもいいと思います。

RedirectToLogin.razor
@inject NavigationManager NavigationManager

@code {
    protected override void OnInitialized()
    {
        NavigationManager.NavigateTo("/MyLogin", true);
    }
}

そして App.razor を以下のように変更します。

App.razor
<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>

CascadingAuthenticationState で全体をくるんで RouteViewAuthorizeRouteView に変更します。AuthorizeRouteViewNotAuthorizedRedirectToLogin を指定することで、認証されていない場合は問答無用で /MyLogin に行くようにしています。

そして、全ページにログインを追加するために Pages/_Imports.razor というファイルを作成して以下の内容を追加します。

_Imports.razor
@attribute [Authorize]

ログイン・ログアウト機能の追加

次にログインページを作りましょう。これは Razor Pages でサクッと作ります。
先程 App.razor で独自ログインページの URL を /MyLogin にしたのでプロジェクト直下に Areas/MyLogin/Pages/Index.cshtml を作ります。

Index.cshtml
@page
@using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous]
@model AuthBlazorServerApp.Areas.MySignin.Pages.IndexModel
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<h1>ログインページ</h1>

<form method="post">
    <input asp-for="UserName" />
    <input type="submit" value="ログイン" />
</form>

ログイン処理も作ります。とりあえず前と同じで名前入れたら認証されるようにします。

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(new[]
        {
            new Claim(ClaimTypes.Name, userName),
            new Claim(ClaimTypes.Role, "User"),
            new Claim(ClaimTypes.Role, "Administrator"),
        }, CookieAuthenticationDefaults.AuthenticationScheme));
        await HttpContext.SignInAsync(
            CookieAuthenticationDefaults.AuthenticationScheme,
            principal);
        return Redirect("/");
    }
}

これでログインが出来ました。動かしてみましょう。プロジェクトをデバッグ実行すると以下のようにログインページに飛びます。
NavigationManager.NavigateTo("/MyLogin", true); の行で例外が出ますが、これはスキップしましょう。

適当に名前を入れてボタンを押すと、トップページに飛びます。ログイン出来ました。

認証用のクッキーはセッションなので、ブラウザーのプロセスが死んだら消える系になってるっぽい。

ログアウト処理を実装する場合はログアウトページを /Area/MyLogin/Pages/Logout.cshtml という名前で作って以下のように実装します。

Logout.cshtml.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace AuthBlazorServerApp.Areas.MyLogin.Pages;

[IgnoreAntiforgeryToken]
public class LogoutModel : PageModel
{
    public async Task<IActionResult> OnPost()
    {
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        return Redirect("/MyLogin");
    }
}

cshtml のページ側は空っぽでいいです。あとは適当なところにログアウトボタンを作って完成です。今回は Shared/NavMenu.razorform タグでログアウトボタンを追加しました。

NavMenu.razor
<div class="top-row ps-3 navbar navbar-dark">
    <div class="container-fluid">
        <a class="navbar-brand" href="">AuthBlazorServerApp</a>
        <button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
            <span class="navbar-toggler-icon"></span>
        </button>
    </div>
</div>

<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>
        <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>
        @* ログアウト ボタン *@
        <div class="nav-item px-3">
            <form method="post" action="MyLogin/Logout">
                <button class="nav-link btn btn-link" type="submit">Logout</button>
            </form>
        </div>
    </nav>
</div>

@code {
    private bool collapseNavMenu = true;

    private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;

    private void ToggleNavMenu()
    {
        collapseNavMenu = !collapseNavMenu;
    }
}

以下のような感じで表示されます。これを押すとログアウトされてログインページにリダイレクトします。

権限によるページ表示・非表示

ロールベースのアクセス制御を使うのが一番楽です。ログイン処理で以下のような形でロールを指定できます。

Index.cshtml.cs
new Claim(ClaimTypes.Role, "User"),
new Claim(ClaimTypes.Role, "Administrator"),

例えば Administrator のロールの人しか表示できないページにしたい場合はページの先頭に以下の属性を追加します。

@attribute [Authorize(Roles = "Administrator")]

例えば Counter.razor に上記のコードを追加してログイン処理にある Administrator の Role を追加している行をコメントアウトしてプログラムを動かすと Counter.razor を表示しようとするとログインページが表示されます。

Administrator のロールじゃない人の場合は、そもそも Counter ページへのリンクを表示したくないケースでは以下のように AuthorizeView コンポーネントの Roles プロパティでロールを指定することで Administrator ロールの人の時にだけ表示されるものを作れます。

以下のコードは Administrator のロールの場合は Counter ページへのリンク、そうじゃない場合は Counter ページへのリンクを表示しないように NavMenu.razor を変更したものになります。

NavMenu.razor
<div class="top-row ps-3 navbar navbar-dark">
    <div class="container-fluid">
        <a class="navbar-brand" href="">AuthBlazorServerApp</a>
        <button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
            <span class="navbar-toggler-icon"></span>
        </button>
    </div>
</div>

<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 Roles="Administrator">
            <div class="nav-item px-3">
                <NavLink class="nav-link" href="counter">
                    <span class="oi oi-plus" aria-hidden="true"></span> Counter
                </NavLink>
            </div>
        </AuthorizeView>
        <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>
        @* ログアウト ボタン *@
        <div class="nav-item px-3">
            <form method="post" action="MyLogin/Logout">
                <button class="nav-link btn btn-link" type="submit">Logout</button>
            </form>
        </div>
    </nav>
</div>

@code {
    private bool collapseNavMenu = true;

    private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;

    private void ToggleNavMenu()
    {
        collapseNavMenu = !collapseNavMenu;
    }
}

Administrator のユーザーで表示すると以下のようなメニューになります。

Administrator じゃないユーザーで表示すると以下のようなメニューになります。

画面内で認証されたユーザーの情報をとってきて色々やりたい場合は以下のように Task<AuthenticationState>CascadingParameter として設定することで await をして AuthenticationState を取って来ることが出来ます。AuthenticationStateUser プロパティが ClaimsPrincipal なので IsInRoleClaims をもとに色々出来ます。

Counter.razor
@page "/counter"

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

<p role="status">Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

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

    private int currentCount = 0;

    private async Task IncrementCount()
    {
        // Administrator じゃない人は 10 までしかカウントアップできない
        var auth = await authenticationState;
        if (auth.User.IsInRole("Administrator") || currentCount < 10)
        {
            currentCount++;
        }
    }
}

まとめ

これで基本的なことは出来るようになりました。次はポリシーベースも見ていこうかなと思います。

Microsoft (有志)

Discussion

Kouta WakamatsuKouta Wakamatsu

現在本記事を参考にさせていただいてログイン認証をblazor serverの.net6で作成しています。
CircuitHandlerを使用してユーザーの状態を管理したく、OnCircuitOpenedAsync内で認証したユーザー情報を取得できないかと模索しています。
CircuitHandlerにAuthenticationStateProviderをDIしてGetAuthenticationStateAsyncで取得しようとしたのですが、SetAuthenticationStateAsyncされていないというエラーがでます。
ログを確認してみてもログイン認証部分は問題なく通っている形跡がありまして、どのようにすれば解決できるか教えていただけますでしょうか。
お手数をおかけしますが、よろしくお願いいたします。