コードファースト ASP.NET Core gRPC でのインターセプター利用

はじめに

protobuf-net.Grpc を使ってコードファーストに ASP.NET Core gRPC サービスを実装する場合でも、 ASP.NET Core の JWT Bearer 認証ミドルウェアを使うことができそうだ、 というところまで前回確認できた。

tnakamura.hatenablog.com

Authorization ヘッダーを詰めた Metadata を毎回送信するのは手間なので、 自動化したいところ。 gRPC にはインターセプターという仕組みが用意されているので、 Authorization ヘッダーを送信するインターセプターを作成すれば良さそうだ。

問題は、protobuf-net.Grpc を使ってコードファーストに実装する場合でも、 インターセプターが使えるのか。 試してみた。

Shared プロジェクトを作成

前回と同じ内容になるが、 クライアントとサーバーの両方で使う、 サービスコントラクトとデータコントラクトを定義する。 プロジェクトの種類は .NET Core クラスライブラリ。 ServiceContractAttribute を使うために System.ServiceModel.Primitives、 CallContext を使うために protobuf-net.Grpc のパッケージを追加しておく。

using System.Runtime.Serialization;
using System.ServiceModel;
using System.Threading.Tasks;
using ProtoBuf.Grpc;

namespace CodeFirstGrpc.Shared
{
    [ServiceContract]
    public interface ITokenServie
    {
        ValueTask<TokenReply> TokenAsync(
            TokenRequest request);
    }

    [ServiceContract]
    public interface IGreetingService
    {
        ValueTask<HelloReply> HelloAsync(CallContext context = default);
    }

    [DataContract]
    public class TokenRequest
    {
        [DataMember(Order = 1)]
        public string UserName { get; set; }
    }

    [DataContract]
    public class TokenReply
    {
        [DataMember(Order = 1)]
        public string Token { get; set; }
    }

    [DataContract]
    public class HelloReply
    {
        [DataMember(Order = 1)]
        public string Message { get; set; }
    }
}

Server プロジェクトを作成

これまた前回と同じになるが、 gRPC サービスプロジェクトを作成し、 コードファーストで gRPC サービスを実装する。

コードファーストでやるために、 protobuf-net.Grpc.AspNetCore パッケージを追加しておく。 Microsoft.AspNetCore.Authentication.JwtBearer パッケージも追加し、 JWT Bearer 認証を組み込む。

using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Threading.Tasks;
using CodeFirstGrpc.Shared;
using Grpc.Core;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using ProtoBuf.Grpc;
using ProtoBuf.Grpc.Server;

namespace CodeFirstGrpc.Server
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                });
    }

    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddCodeFirstGrpc();

            services.AddAuthorization(options =>
            {
                // デフォルトの認証スキーマのポリシーを簡単なものに上書き
                options.AddPolicy(JwtBearerDefaults.AuthenticationScheme, policy =>
                {
                    policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme);
                    policy.RequireClaim(ClaimTypes.Name);
                });
            });

            // JWT Bearer 認証を組み込む
            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddJwtBearer(options =>
                {
                    // サンプルを簡略化するため検証機能を OFF にする
                    // 本番でこんなことをしてはダメ
                    options.TokenValidationParameters =
                        new TokenValidationParameters
                        {
                            ValidateAudience = false,
                            ValidateIssuer = false,
                            ValidateActor = false,
                            ValidateLifetime = true,
                            IssuerSigningKey = SecurityKey
                        };
                });
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            app.UseRouting();

            // パイプラインに認証と認可を組み込む
            app.UseAuthentication();
            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapGrpcService<TokenService>();
                endpoints.MapGrpcService<GreetingService>();
            });
        }

        internal static readonly SymmetricSecurityKey SecurityKey =
            new SymmetricSecurityKey(Guid.NewGuid().ToByteArray());
    }

    public class TokenService : ITokenServie
    {
        public ValueTask<TokenReply> TokenAsync(TokenRequest request)
        {
            var token = GenerateJwtToken(request.UserName);
            return new ValueTask<TokenReply>(new TokenReply
            {
                Token = token,
            });
        }

        string GenerateJwtToken(string name)
        {
            if (string.IsNullOrEmpty(name))
            {
                throw new InvalidOperationException("Name is not specified.");
            }

            var claims = new[]
            {
                new Claim(ClaimTypes.Name, name),
            };
            var credentials = new SigningCredentials(
                Startup.SecurityKey,
                SecurityAlgorithms.HmacSha256);
            var token = new JwtSecurityToken(
                "ExampleServer",
                "ExampleClients",
                claims,
                expires: DateTime.Now.AddSeconds(60),
                signingCredentials: credentials);
            return JwtTokenHandler.WriteToken(token);
        }


        readonly JwtSecurityTokenHandler JwtTokenHandler = new JwtSecurityTokenHandler();
    }

    // 認証必須のサービス
    [Authorize]
    public class GreetingService : IGreetingService
    {
        public ValueTask<HelloReply> HelloAsync(CallContext context = default)
        {
            // 認証に成功していれば HttpContext からユーザーの情報を取得できるはず
            var httpContext = context.ServerCallContext.GetHttpContext();
            var userName = httpContext.User.Identity.Name;
            return new ValueTask<HelloReply>(new HelloReply
            {
                Message = $"Hello, {userName}.",
            });
        }
    }
}

Client プロジェクトを作成

長い前置きが終わって、ここからが本番。

NET Core コンソールアプリのプロジェクトを作成し、 gRPC サービスを呼び出すためのクライアントを実装する。 コードファーストで実装した gRPC サービスを呼び出すために、 Grpc.Net.Client と protobuf-net.Grpc のパッケージを参照しておく。

using System;
using System.Threading.Tasks;
using CodeFirstGrpc.Shared;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Grpc.Net.Client;
using ProtoBuf.Grpc.Client;
using ProtoBuf.Grpc.Configuration;

namespace CodeFirstGrpc.Client
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var channel = GrpcChannel.ForAddress("https://localhost:5001");

            // JWT Bearer トークン無しで保護されたサービスを呼び出す
            try
            {
                var greetingClient1 = channel.CreateGrpcService<IGreetingService>();
                _ = await greetingClient1.HelloAsync();
            }
            catch (RpcException ex)
            {
                Console.WriteLine(ex.Message);
            }

            // JWT Bearer トークン取得
            var tokenClient = channel.CreateGrpcService<ITokenServie>();
            var tokenReply = await tokenClient.TokenAsync(new TokenRequest
            {
                UserName = "Kubo",
            });

            // Metadata に常に Authorization を追加するインターセプターを利用する
            // Intercept の戻り値は CallInvoker なので、CreateGrpcService<T> は使えない。
            // 代わりに ClientFactory の CreateClient<T>(CallInvoker) を使う。
            var callInvoker = channel.Intercept(new AuthorizationInterceptor(tokenReply.Token));
            var greetingClient2 = ClientFactory.Default.CreateClient<IGreetingService>(callInvoker);
            var helloRepoly = await greetingClient2.HelloAsync();
            Console.WriteLine($"Message: {helloRepoly.Message}");

            Console.ReadLine();
        }
    }

    // Metadata に常に Authorization を追加するインターセプター
    class AuthorizationInterceptor : Interceptor
    {
        readonly string jwt;

        public AuthorizationInterceptor(string jwt)
        {
            this.jwt = jwt;
        }

        public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
            TRequest request,
            ClientInterceptorContext<TRequest, TResponse> context,
            AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
        {
            AddAuthorizationMetadata(ref context);

            return continuation(request, context);
        }

        public override AsyncClientStreamingCall<TRequest, TResponse> AsyncClientStreamingCall<TRequest, TResponse>(
            ClientInterceptorContext<TRequest, TResponse> context,
            AsyncClientStreamingCallContinuation<TRequest, TResponse> continuation)
        {
            AddAuthorizationMetadata(ref context);

            return continuation(context);
        }

        public override AsyncServerStreamingCall<TResponse> AsyncServerStreamingCall<TRequest, TResponse>(
            TRequest request,
            ClientInterceptorContext<TRequest, TResponse> context,
            AsyncServerStreamingCallContinuation<TRequest, TResponse> continuation)
        {
            AddAuthorizationMetadata(ref context);

            return continuation(request, context);
        }

        public override AsyncDuplexStreamingCall<TRequest, TResponse> AsyncDuplexStreamingCall<TRequest, TResponse>(
            ClientInterceptorContext<TRequest, TResponse> context,
            AsyncDuplexStreamingCallContinuation<TRequest, TResponse> continuation)
        {
            AddAuthorizationMetadata(ref context);

            return continuation(context);
        }

        void AddAuthorizationMetadata<TRequest, TResponse>(
            ref ClientInterceptorContext<TRequest, TResponse> context)
            where TRequest : class
            where TResponse : class
        {
            var headers = context.Options.Headers;

            // ヘッダーのコレクションが無ければ作成する。
            // 作成したヘッダーのコレクションを後続のインターセプターに渡すために、
            // context も作り直す必要がある。
            if (headers == null)
            {
                headers = new Metadata();
                var options = context.Options.WithHeaders(headers);
                context = new ClientInterceptorContext<TRequest, TResponse>(
                    context.Method,
                    context.Host,
                    options);
            }

            // Authorization ヘッダーを追加
            headers.Add("Authorization", $"Bearer {jwt}");
        }
    }
}

Authorization ヘッダーを毎回自動で送信するためのインターセプターを作成した。 作成したインターセプターを組み込む場合、 今までお世話になってきた CreateGrpcService<T> が使えないので注意。 代わりに ClientFactory.CreateClient<T>(CallInvoker) を使えばいいみたい。

実行結果

Authorization ヘッダー無しで認証必須の gRPC サービスを呼び出すと、ちゃんとエラーになった。 インターセプターを組み込んだ場合は呼び出し成功。

f:id:griefworker:20200318163204p:plain

おわりに

インターセプターを自作して組み込むことで、 クライアントからサーバーに任意のメタデータを自動で送信できそう。

WCF から gRPC への移行は、 protobuf-net.Grpc を使ってコードファーストに行えそうな目途がついた。