🎃

Blazor WASM でログイン後に Azure AD のセキュリティグループで認可をする

2022/12/14に公開

今までいくつか Blazor で認証をするような機能を紹介してきましたが、今回は Azure AD で認証したあとにユーザーが所属しているセキュリティグループに応じて認可をしていこうと思います。

例えばセキュリティグループ admins に所属していたら表示を変えたりボタンを押したときの処理を変えたりといったことがしたいという内容です。ちなみに正攻法でやると以下のドキュメントにあるようにセキュリティグループをロールに割り当てるような設定をする方法だと思います。

https://learn.microsoft.com/ja-jp/azure/active-directory/develop/howto-add-app-roles-in-azure-ad-apps

ただ、これをやろうとしたら私の手元にある検証用環境の Azure AD のプランではレベルが足りませんでした…。なのでセキュリティグループを直接アプリ内で判断して認可をする方法になっています。

このネタは Twitter で何か記事のネタがないか探していたらリクエストしていただいた内容になります。リクエストありがとうございました!

ログイン機能をつけよう

ということでやっていきましょう。とりあえず普通に認証するだけのアプリを作るならプロジェクトの新規作成時に認証の欄に Microsoft identity platform を設定しておくのが一番楽です。

このようにプロジェクトを作る過程で Azure AD に対してアプリケーション登録も作成してくれます。至れり尽くせり。

この設定を行うことでプロジェクトにどんなことが行われているのかといったことや、Azure AD にどういうアプリ登録が作られるのかといったところについては以下のドキュメントを参照してください。

https://learn.microsoft.com/ja-jp/aspnet/core/blazor/security/webassembly/standalone-with-azure-active-directory?view=aspnetcore-7.0

これだけで以下のようにログイン機能を持ったアプリケーションが出来上がりました。

画面の右上にログインのためのリンクがあります。

ログインをすると右上にログインをしたユーザーの名前が表示されます。

セキュリティ グループをクレームに入れよう

では、ここにセキュリティグループでの認可を追加していきます。その前に Index.cshtml を以下のように変更してクレームの中身が見えるようにしておこうと思います。

Index.cshtml
@page "/"
@using System.Security.Claims;

<PageTitle>Index</PageTitle>

<h1>Hello, world!</h1>

@if (Claims is not null)
{
    <ul>
        @foreach (var claim in Claims)
        {
            <li>@claim.Type: @claim.Value</li>
        }
    </ul>
}

@code {
    [CascadingParameter]
    private Task<AuthenticationState> AuthenticationState { get; set; } = default!;
    private IEnumerable<Claim>? Claims { get; set; }

    protected override async Task OnInitializedAsync()
    {
        Claims = (await AuthenticationState).User.Claims;
    }
}

この状態で実行してログインを行うと以下のようにクレームの一覧が表示されるようになります。

クレームの一覧が出るようになったのでセキュリティグループをここに含めるようにします。これをするには Azure AD のアプリケーション登録 (https://entra.microsoft.com からいけます) で、今回用に作られたアプリを開いてマニフェストを開きます。

そして、マニフェストの groupMembershipClaims の値を以下のように null から SecurityGroup に変更します。

"groupMembershipClaims": "SecurityGroup",

こうすることでクレームに groups というキーでログインしたユーザーが所属しているセキュリティグループのオブジェクト ID が含まれるようになります。どのセキュリティグループにも所属していないユーザーの場合は groups というキー自体がありません。この groups ですが配列の書式の文字列で格納されています。例えば ["xxxx-xxxxx-xxxxx-xxxxxxx","xxxxx-xxxxx-xxxxxx-xxxxxx"] のような形です。

実際に何処のセキュリティグループにも所属していないユーザーを作成してログインをすると以下のように表示されました。

セキュリティグループに所属しているユーザーの場合は以下のようにセキュリティグループのオブジェクト ID が格納されています。

Azure AD に適当なセキュリティグループ (今回は admin-security-group という名前で作りました) を作って、そこに何処のセキュリティグループにも所属していなかった Test Taro さんを所属させてみました。そうすると以下のように groups のクレームが追加されます。

今回はこれの有無で認可をしたいと思います。
このような情報で認可を行うにはポリシーベースの認可を使います。最初に Azure AD のレベルが足りなくて出来なかったロールの割り当てが出来ていたらロールベースの認可が出来ていたと思うのですが、まぁ大した手間ではないのでさくっとやってしまいましょう。
ポリシーベースの認可自体の詳細については、以下のドキュメントを参照してください。

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

ではサクッと追加していきます。

Program.cs
using BlazorApp13;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using System.Text.Json;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

builder.Services.AddMsalAuthentication(options =>
{
    builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
});

// ここから追加
builder.Services.AddAuthorizationCore(options =>
{
    // admin-security-group のオブジェクト ID が groups に入っていたら admin として判定するポリシー
    options.AddPolicy("admin", policy => policy.RequireAssertion(context =>
    {
        var groupsString = context.User.Claims.FirstOrDefault(x => x.Type == "groups")?.Value;
        
        if (string.IsNullOrWhiteSpace(groupsString)) return false;

        var groups = JsonSerializer.Deserialize<string[]>(groupsString);
        return groups?.Any(x => x == "968a3967-8393-49b5-a31f-4b1b13f42094") ?? false;
    }));
});

await builder.Build().RunAsync();

今回は具体的な値のイメージがつきやすいように GUID などをハードコーディングしていますが、本番ではちゃんと構成ファイルなどで変更できるようにしておいたほうがいいでしょうね。これで admin-security-group に所属している人は admin という扱いをするようなポリシーが出来ました。ここまで出来たら AuthorizeViewIAuthorizationService を使って判定する処理を書くだけになります。

例えば Shared/NavMenu.razor でページへのリンクを定義している箇所を以下のように変更することで Fetch data へのリンクを admin のポリシーを持っている人にしか表示しないようにできます。

NavMenu.razor
<div class="@NavMenuCssClass nav-scrollable" @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>
        <!-- ここから -->
        <AuthorizeView Policy="admin">
            <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>

もちろんこれだけだと URL を直指定されたら admin じゃないユーザーが Fetch data のページを表示することが出来るので以下のように Authorize 属性をつけてポリシーを満たさないユーザーをはじくようにしましょう。

FetchData.razor
@page "/fetchdata"
@using Microsoft.AspNetCore.Authorization;
@attribute [Authorize(Policy = "admin")]
@inject HttpClient Http

<PageTitle>Weather forecast</PageTitle>

...以下略...

以下のように左が admin のポリシーを満たさないユーザーのメニューで右が admin のポリシーを満たすユーザーで Fetch data のメニューの表示が変わっていることが確認できます。

以下のように URL 直指定をしても左の権限のないユーザーはちゃんとアクセスできないようになっています。

admin かどうかで実行する処理を変えたい場合は以下のように IAuthorizationService を使って行うことが出来ます。管理者のときだけ圧倒的にカウントアップするようにしています。

Counter.razor
@page "/counter"
@using Microsoft.AspNetCore.Authorization;
@inject IAuthorizationService _authorizationService
<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

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

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

@code {
    [CascadingParameter]
    private Task<AuthenticationState> AuthenticationState { get; set; } = default!;
    private int currentCount = 0;

    private async Task IncrementCountAsync()
    {
        if (await IsAdminAsync())
        {
            // 管理者用の下駄
            currentCount += 10000;
        }
        else
        {
            // 普通の人は地道にがんばって
            currentCount++;
        }
    }

    // admin のポリシーを満たすかどうかチェック
    private async Task<bool> IsAdminAsync()
    {
        // 認証の状態をとって
        var authenticationState = await AuthenticationState;
        // ログイン状態かチェックして
        if (authenticationState is { User.Identity.IsAuthenticated: false }) return false;
        // admin かどうか確認する
        return (await _authorizationService.AuthorizeAsync(authenticationState.User, "admin")).Succeeded;
    }
}

この例ではボタンを押すたびに admin かどうかチェックをしていますが、実際は画面の表示段階で一度チェックするだけでいいと思います。

まとめ

ということでセキュリティグループで認可を行う処理を試してみました。慣れてしまえばそんなに難しくないと思いますが知らないと、なかなか情報にたどり着くのが大変かもしれません。特にマニフェスト編集のところとか…。あとはお金を払って Azure AD のレベルをあげることで素直にロールをセキュリティグループに割り当てることが出来れば IsInRole メソッドなどで、もう少しお手軽に判定したりできたかもしれませんが私は Azure AD のレベルが低いので試せませんでした…。

ということで、社内システムを Blazor で組む時はセキュリティグループを使って認可をしてみるのもありかもしれないですね!

あと、今回は ASP.NET Core Blazor WASM なのでクライアントサイドで実行する機能に差をつけましたが、実際は画面から呼び出す Web API 側でもちゃんと権限チェックするようにしましょう。画面側なんていくらでも不正できちゃうので信頼ダメ絶対。

Microsoft (有志)

Discussion