かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

ASP.NET Core 3.0 + gRPC + WPF on .NET Core 3.0 で Azure AD を使って認証・認可

過去記事

blog.okazuki.jp

blog.okazuki.jp

本文

前回、認証だけはやりました。今回はユーザーの権限とかを見て何かしたいとか、この API は呼べる、呼べないを構成していきたいと思います。

Azure AD のグループ機能で認可してみよう

Azure AD のグループでユーザーをグループに所属させることが出来ます。私の環境ではグループ作る時にセキュリティと Office 365 が選択出来ますが今回はセキュリティの方で適当にグループを作ります。

f:id:okazuki:20190904193927p:plain

グループを作って適当にユーザーを所属させます。そしてグループを識別するためのオブジェクト IDを控えておきます。以下のようにグループの一覧にもちらっとオブジェクト IDが表示されますが

f:id:okazuki:20190904194040p:plain

グループを選択するとオブジェクト IDがコピーできます。

f:id:okazuki:20190904194608p:plain

グループが出来たので、次に Azure AD に登録したアプリをいじっていきます。アプリにわたっていくクレームにユーザーのセキュリティグループがわたるようにします。Azure AD の「アプリの登録」からサーバー側のアプリを開きます。そして「マニフェスト」を選択します。すると JSON が表示されます。 その中に ""groupMembershipClaims": null, という行があるので "groupMembershipClaims": "SecurityGroup", に変更して保存します。

f:id:okazuki:20190904195137p:plain

この状態でアプリを実行して適当なユーザーでログインして gRPC のサービスを呼び出してみましょう。gRPC のサービスのメソッドの中でブレークポイントで止めて HttpContext の中の User.Identity.Claims を覗いてみると groups をキーにしてグループのオブジェクト ID がわたってきてることが確認できます。

f:id:okazuki:20190904195732p:plain

これの有無で API が呼べる・呼べないを制御出来れば良さそうです。試しに admins グループじゃないと呼べない処理を 1 つ追加してみます。

.proto ファイルに GreetForAdmin という名前のメソッドをはやします。

syntax = "proto3";

option csharp_namespace = "GrpcSample";

service Greeter {
    rpc Greet (GreetRequest) returns (GreetReply);
    rpc GreetForAdmin (GreetRequest) returns (GreetReply);
}

message GreetRequest {
    string name = 1;
}

message GreetReply {
    string message = 1;
}

サーバー側のサービスの実装は以下のようにしてみました。管理者なので Hello と気軽に挨拶するのではなく Dear にしてみました。

public override Task<GreetReply> GreetForAdmin(GreetRequest request, ServerCallContext context)
{
    var identity = context.GetHttpContext().User.Identity;
    return Task.FromResult(new GreetReply
    {
        Message = $"Dear {request.Name}, IsAuthorized: {identity.IsAuthenticated}, Your account is {identity.Name}",
    });
}

権限の高い人には媚を売っていくスタイル。では、これを admins グループの人のみが呼べるようにします。(今のままだと誰でも呼べる)

Startup.csConfigureServices メソッドにある services.AddAuthorization(); を以下のように変えます。

services.AddAuthorization(options =>
{
    options.AddPolicy("Admins", policy => policy.RequireClaim("groups", "admins グループのオブジェクト ID"));
});

Admins というポリシーを追加しています。ポリシーの中身はクレームの中に groups に admins のオブジェクト ID があるということです。 ポリシーの適用は、Authorize 属性で行います。

[Authorize("Admins")]
public override Task<GreetReply> GreetForAdmin(GreetRequest request, ServerCallContext context)
{
    var identity = context.GetHttpContext().User.Identity;
    return Task.FromResult(new GreetReply
    {
        Message = $"Dear {request.Name}, IsAuthorized: {identity.IsAuthenticated}, Your account is {identity.Name}",
    });
}

では、クライアント側にボタンを追加して GreetForAdmin を呼び出す処理を追加しましょう。 MainPage.xaml を編集してボタンを追加します。

<Window
    x:Class="GrpcClient.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:local="clr-namespace:GrpcClient"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="MainWindow"
    Width="800"
    Height="450"
    mc:Ignorable="d">
    <StackPanel>
        <TextBox x:Name="textBoxName" />
        <Button Click="CallGrpcServiceButton_Click" Content="Call gRPC service" />
        <Button Click="CallGrpcServiceForAdminButton_Click" Content="Call gRPC service for Admin" />
    </StackPanel>
</Window>

クリックイベントハンドラーは以下のように GreetForAdminAsync メソッドを呼ぶようにします。

private async void CallGrpcServiceForAdminButton_Click(object sender, RoutedEventArgs e)
{
    var accessToken = await GetAccessTokenAsync();

    using (var client = new HttpClient
    {
        BaseAddress = new Uri("https://localhost:5001")
    })
    {
        var greetServices = Grpc.Net.Client.GrpcClient.Create<Greeter.GreeterClient>(client);
        var response = await greetServices.GreetForAdminAsync(new GreetRequest
        {
            Name = textBoxName.Text,
        },
        new Grpc.Core.Metadata
        {
            { "Authorization", $"Bearer {accessToken}" },
        });
        MessageBox.Show(response.Message);
    }
}

実行してみます。admins グループに所属してないユーザーでログインして Admin のほうのボタンを押すと例外が出ます。

f:id:okazuki:20190904200851p:plain

admins グループに所属しているユーザーでログインして Admin の方のボタンを押すとちゃんと処理が呼べます。

f:id:okazuki:20190904201332p:plain

クライアント側でログインユーザーのグループの判別

さて、実際のプログラムではログインユーザーの所属するグループに応じてボタンとかメニューの表示・非表示を制御することになると思います。 JWT トークンをパースすれば、その中にクレームが入っているのでそれでやります。

自前でパースしてもいいのですが System.IdentityModel.Tokens.Jwt というパッケージが公開されているので、それを使いましょう。

www.nuget.org

JwtSecuretyToken クラスにトークンを渡してやればいい感じに扱えます。Claims プロパティから目的のクレームがあるか探してチェックしたりすればいい感じですね。 以下のようにすれば呼び出し前に admins に所属しているかどうか確認できます。

private async void CallGrpcServiceForAdminButton_Click(object sender, RoutedEventArgs e)
{
    var accessToken = await GetAccessTokenAsync();

    var jwt = new JwtSecurityToken(accessToken);
    var groupId = jwt.Claims.FirstOrDefault(x => x.Type == "groups")?.Value;
    if (groupId != "admins グループのオブジェクト ID")
    {
        MessageBox.Show("You are not a member of admins group, right? Please do not click this button.");
        return;
    }

    using (var client = new HttpClient
    {
        BaseAddress = new Uri("https://localhost:5001")
    })
    {
        var greetServices = Grpc.Net.Client.GrpcClient.Create<Greeter.GreeterClient>(client);
        var response = await greetServices.GreetForAdminAsync(new GreetRequest
        {
            Name = textBoxName.Text,
        },
        new Grpc.Core.Metadata
        {
            { "Authorization", $"Bearer {accessToken}" },
        });
        MessageBox.Show(response.Message);
    }
}

実行して admins グループにいないユーザーでサインインすると以下のように、ちゃんと呼び出し前にチェック出来てることがわかります。

f:id:okazuki:20190904202613p:plain

一般的な注意点ですが、クライアントサイドでの権限チェックは処理を呼び出す呼び出さないといったことや、要素の表示・非表示くらいにしようねってのがあります。本当に権限が必要な処理はサーバーサイドでも、きちんとチェックしましょう。

アプリでロールを管理しよう

グループはグループでいいんですが、これは Azure AD 全体に影響があるものになります。 部門単位でちょっと開発するアプリで、そこの設定変更は影響が大きすぎるので、アプリ内で適当にロールみたいなのを割り当てたいといったこともあります。

その場合は、以下のドキュメントにあるように、Azure AD でアプリ単位でロールを定義出来ます。

docs.microsoft.com

Azure AD の「アプリの登録」でサーバー側のアプリを開きます。そして、マニフェストを開きます。そして、appRoles に JSON 手書きでロールを定義していきます。硬派ですね…。

例えば Users と Admins みたいなロールを追加するなら以下のような感じです。id には GUID を指定しますが、これは PowerShell Core で New-Guid と打って生成するのが楽でした。

   "appRoles": [
        {
            "allowedMemberTypes": [
                "User"
            ],
            "description": "Administrators.",
            "displayName": "Admins",
            "id": "fc998089-b40c-40c1-af3c-161382ac6422",
            "isEnabled": true,
            "lang": null,
            "origin": "Application",
            "value": "Admins"
        },
        {
            "allowedMemberTypes": [
                "User"
            ],
            "description": "Users.",
            "displayName": "Users",
            "id": "237d8777-e380-4b5d-bb4a-192c126640f5",
            "isEnabled": true,
            "lang": null,
            "origin": "Application",
            "value": "Users"
        }
    ],

次に、ユーザーにこのロールを割り当てます。これは GUI でやれます。安心。やりかたは Azure AD の「エンタープライズ アプリケーション」を選択してサーバー側のアプリを開きます。

そして「ユーザーとグループ」を選択して「ユーザーの追加」を選択します。

f:id:okazuki:20190904203652p:plain

そうするとユーザー(複数人選択可能)とロール(先ほど JSON で手書き追加したやつ)が選択できるのでお好みな感じで割り当てます。 このように構成しておくと、http://schemas.microsoft.com/ws/2008/06/identity/claims/role という ClaimType で Role に指定した Value が入ってきます。なので、今回は Admins じゃないとダメにしたいので、Startup.cs の ConfigureServices の AddAuthorization メソッドを以下のように書き換えます。

services.AddAuthorization(options =>
{
    options.AddPolicy("Admins", policy => 
        //policy.RequireClaim("groups", "admins グループのオブジェクト ID ")
        policy.RequireClaim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", "Admins") // 今度はこっちね
    );
});

クライアント側で JwtToken を解析した結果の Claim には Type が roles に入ってるので以下のように書き換える感じになります。

var jwt = new JwtSecurityToken(accessToken);
//var groupId = jwt.Claims.FirstOrDefault(x => x.Type == "groups")?.Value;
//if (groupId != "admins グループのオブジェクト ID")
//{
//    MessageBox.Show("You are not a member of admins group, right? Please do not click this button.");
//    return;
//}

var roleName = jwt.Claims.FirstOrDefault(x => x.Type == "roles")?.Value;
if (roleName != "Admins")
{
    MessageBox.Show("You are not a member of admins group, right? Please do not click this button.");
    return;
}

これで、Azure AD のセキュリティグループで設定したときと同じように動きます。

まとめ

Azure AD が導入されているなら、これでログイン出来る社内アプリを作らない手はないので使ってみましょう。

ソースコードは、これまでの GitHub のリポジトリーに moreauth というブランチを作ってそこに上げてます。

GitHub - runceel/GrpcNetCoreSample at moreauth