【问题标题】:gRPC-Web Channel Authentication with Blazor Webassembly via Dependency Injection通过依赖注入使用 Blazor Webassembly 进行 gRPC-Web 通道身份验证
【发布时间】:2020-07-23 13:59:57
【问题描述】:

我正在使用身份验证在 Blazor Webassembly 中测试 gRPC-Web,并遇到了一些关于如何干净访问我的 gRPC 通道的问题。

没有身份验证有一种非常简单和干净的方法,如 grpc-dotnet https://github.com/grpc/grpc-dotnet/tree/master/examples/Blazor 的 Blazor 示例中所述。

频道的提供:

builder.Services.AddSingleton(services =>
{
    // Get the service address from appsettings.json
    var config = services.GetRequiredService<IConfiguration>();
    var backendUrl = config["BackendUrl"];

    var httpClient = new HttpClient(new GrpcWebHandler(GrpcWebMode.GrpcWebText, new HttpClientHandler()));

    var channel = GrpcChannel.ForAddress(backendUrl, new GrpcChannelOptions { HttpClient = httpClient });

    return channel;
});

Razor 文件中的用法

@inject GrpcChannel Channel

直接在 razor 文件中添加身份验证并在那里创建通道也没有那么复杂

@inject IAccessTokenProvider AuthenticationService
...

@code {
...
var httpClient = new HttpClient(new GrpcWebHandler(GrpcWebMode.GrpcWebText, new HttpClientHandler()));
var tokenResult = await AuthenticationService.RequestAccessToken();

if (tokenResult.TryGetToken(out var token))
{
    var _token = token.Value;

    var credentials = CallCredentials.FromInterceptor((context, metadata) =>
    {
        if (!string.IsNullOrEmpty(_token))
        {
            metadata.Add("Authorization", $"Bearer {_token}");
        }
        return Task.CompletedTask;
    });

    //SslCredentials is used here because this channel is using TLS.
    //Channels that aren't using TLS should use ChannelCredentials.Insecure instead.
    var channel = GrpcChannel.ForAddress(baseUri, new GrpcChannelOptions
    {
        Credentials = ChannelCredentials.Create(new SslCredentials(), credentials)
    });

但这会将许多必需的逻辑移到剃刀文件中。有没有办法结合这些并通过注入提供经过身份验证的 grpc 通道?

【问题讨论】:

    标签: .net dependency-injection grpc blazor blazor-client-side


    【解决方案1】:

    经过大量额外测试,我找到了解决方案。 虽然不完美,但到目前为止运行良好。

    启动时注册频道

    builder.Services.AddSingleton(async services =>
    {
        var httpClient = new HttpClient(new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler()));
        var baseUri = "serviceUri";
    
        var authenticationService = services.GetRequiredService<IAccessTokenProvider>();
    
        var tokenResult = await authenticationService.RequestAccessToken();
    
        if(tokenResult.TryGetToken(out var token)) {
            var credentials = CallCredentials.FromInterceptor((context, metadata) =>
            {
                if (!string.IsNullOrEmpty(token.Value))
                {
                    metadata.Add("Authorization", $"Bearer {token.Value}");
                }
                return Task.CompletedTask;
            });
    
            var channel = GrpcChannel.ForAddress(baseUri, new GrpcChannelOptions { HttpClient = httpClient, Credentials = ChannelCredentials.Create(new SslCredentials(), credentials) });
    
            return channel;
        }
    
        return GrpcChannel.ForAddress(baseUri, new GrpcChannelOptions() { HttpClient = httpClient });
    
    });
    

    由于通道是使用异步注册的,所以它必须作为任务注入

    @inject Task<GrpcChannel> Channel
    

    【讨论】:

    • 在 Core 5 中,无法从单例服务中检索 IAccessTokenProvider,因为它是作用域的。将第一行更改为 AddScoped 并再次工作。提示 baseUri 也可以使用 'new Uri(builder.HostEnvironment.BaseAddress)' 设置
    【解决方案2】:

    我根据 Microsoft 在 .NET Core 3.2 中的 Hosted Blazor WebAssembly 项目的新项目模板解决了这个问题。我从 BaseAddressAuthorizationMessageHandler 复制了代码,但注释掉了令牌不可用时引发的异常,并将其添加到 Program.cs 中的 HttpClient:

    Program.cs

    builder.Services.AddHttpClient("SampleProject.ServerAPI", client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
        .AddHttpMessageHandler<GrpcWebHandler>()
        .AddHttpMessageHandler<GrpcAuthorizationMessageHandler>();
    
    builder.Services.AddSingleton(services =>
    {
        // Create a gRPC-Web channel pointing to the backend server
        var httpClient = services.GetRequiredService<HttpClient>();
        var baseUri = services.GetRequiredService<NavigationManager>().BaseUri;
        var channel = GrpcChannel.ForAddress(baseUri, new GrpcChannelOptions { HttpClient = httpClient });
    
        // Now we can instantiate gRPC clients for this channel
        return new Products.ProductsClient(channel);
    });
    

    GrpcAuthorizationMessageHandler.cs (source):

    public class GrpcAuthorizationMessageHandler : DelegatingHandler
    {
        private readonly IAccessTokenProvider _provider;
        private readonly NavigationManager _navigation;
        private AccessToken _lastToken;
        private AuthenticationHeaderValue _cachedHeader;
        private Uri[] _authorizedUris;
        private AccessTokenRequestOptions _tokenOptions;
    
        public GrpcAuthorizationMessageHandler(
            IAccessTokenProvider provider,
            NavigationManager navigation)
        {
            _provider = provider;
            _navigation = navigation;
            ConfigureHandler(new[] { _navigation.BaseUri });
        }
    
        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            var now = DateTimeOffset.Now;
            if (_authorizedUris == null)
            {
                throw new InvalidOperationException($"The '{nameof(AuthorizationMessageHandler)}' is not configured. " +
                    $"Call '{nameof(AuthorizationMessageHandler.ConfigureHandler)}' and provide a list of endpoint urls to attach the token to.");
            }
    
            if (_authorizedUris.Any(uri => uri.IsBaseOf(request.RequestUri)))
            {
                if (_lastToken == null || now >= _lastToken.Expires.AddMinutes(-5))
                {
                    var tokenResult = _tokenOptions != null ?
                        await _provider.RequestAccessToken(_tokenOptions) :
                        await _provider.RequestAccessToken();
    
                    if (tokenResult.TryGetToken(out var token))
                    {
                        _lastToken = token;
                        _cachedHeader = new AuthenticationHeaderValue("Bearer", _lastToken.Value);
                    }
                    // this exception was commented out to be used with the GrpcWebHandler
                    // else
                    // {
                        // throw new AccessTokenNotAvailableException(_navigation, tokenResult, _tokenOptions?.Scopes);
                    // }
                }
    
                // We don't try to handle 401s and retry the request with a new token automatically since that would mean we need to copy the request
                // headers and buffer the body and we expect that the user instead handles the 401s. (Also, we can't really handle all 401s as we might
                // not be able to provision a token without user interaction).
                request.Headers.Authorization = _cachedHeader;
            }
    
            return await base.SendAsync(request, cancellationToken);
        }
    
        public GrpcAuthorizationMessageHandler ConfigureHandler(
            IEnumerable<string> authorizedUrls,
            IEnumerable<string> scopes = null,
            string returnUrl = null)
        {
            if (_authorizedUris != null)
            {
                throw new InvalidOperationException("Handler already configured.");
            }
    
            if (authorizedUrls == null)
            {
                throw new ArgumentNullException(nameof(authorizedUrls));
            }
    
            var uris = authorizedUrls.Select(uri => new Uri(uri, UriKind.Absolute)).ToArray();
            if (uris.Length == 0)
            {
                throw new ArgumentException("At least one URL must be configured.", nameof(authorizedUrls));
            }
    
            _authorizedUris = uris;
            var scopesList = scopes?.ToArray();
            if (scopesList != null || returnUrl != null)
            {
                _tokenOptions = new AccessTokenRequestOptions
                {
                    Scopes = scopesList,
                    ReturnUrl = returnUrl
                };
            }
    
            return this;
        }
    }
    

    这是其背后的基本原理。

    根据 Steve Sanderson 的this blog post,您只需将 GrpcWebHandler 添加到 HttpClient 即可使用 GrpcWeb。但是,如果您尝试将 BaseAddressAuthorizationMessageHandler 与 GrpcWebHandler 一起使用,您将在用户未经身份验证时收到带有 StatusCode=Internal 的 RpcException。

    查看代码后发现异常的原因是授权处理程序在token不可用时抛出异常,而GrpcWebHandler将其作为内部异常捕获。如果您添加一个不会抛出该异常的自定义消息处理程序(如上面的那个),GrpcWebHandler 将抛出 StatusCode=Unauthenticated 的正确 RcpException,然后您可以相应地处理它,例如通过重定向到登录页面。

    这是一个示例,说明如何在 razor 页面中使用 GrpcClient 而无需添加额外的授权代码:

    @inject CustomClient grpcClient
    @inject NavigationManager navManager
    
    @code {
        public async Task MakeRequest() {
            var request = new Request();
            try
            {
                var reply = await grpcClient.MakeRequestAsync(request);
            }
            catch (Grpc.Core.RpcException ex) when (ex.StatusCode == StatusCode.Unauthenticated)
            {
                NavigationManager.NavigateTo($"/authentication/login/?returnUrl={NavigationManager.BaseUri}your-page");
            }
        }
    }
    

    【讨论】:

      【解决方案3】:

      我尝试在我的 Blazor WASM 应用程序中使用来自 https://github.com/grpc/grpc-dotnet/tree/master/examples#ticketer 的 JamesNK 的“Ticketer”示例中的示例代码执行类似的操作,并且它有效。

      ticketer 展示了如何使用 gRPC 进行身份验证和 ASP.NET Core 中的授权。此示例标记了一个 gRPC 方法 带有 [Authorize] 属性。客户端只能调用该方法,如果 它已通过服务器身份验证并传递了有效的 JWT 令牌 使用 gRPC 调用。

      我在 'Client/Shared/NavMenu.cs' (OnInitializedAsync()) 中创建了一个令牌,并在调用其他页面中的 gRPC 服务时使用该令牌。

      【讨论】:

        【解决方案4】:

        您可以稍微更改一下并跳过异步。它不漂亮,但你摆脱了任务频道。我还没有尝试过下面的代码,这只是一个关于如何完成的想法。

        builder.Services.AddSingleton(services =>
        {
        var httpClient = new HttpClient(new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler()));
        var baseUri = "serviceUri";
        
        var authenticationService = services.GetRequiredService<IAccessTokenProvider>();
        
        IAccessTokenProvider tokenResult;
        Task.Run(() => token = await authenticationService.RequestAccessToken());
        
        int i = 0;
        while (true)
        {
           if (tokenResult.TryGetToken(out var tokenResult) || i > 10)
              break;
           i++;
        
           Thread.Sleep(10);
        }
        
        if(tokenResult.TryGetToken(out var token)) {
            var credentials = CallCredentials.FromInterceptor((context, metadata) =>
            {
                if (!string.IsNullOrEmpty(token.Value))
                {
                    metadata.Add("Authorization", $"Bearer {token.Value}");
                }
                return Task.CompletedTask;
            });
        
            var channel = GrpcChannel.ForAddress(baseUri, new GrpcChannelOptions { HttpClient = httpClient, Credentials = ChannelCredentials.Create(new SslCredentials(), credentials) });
        
            return channel;
        }
        
        return GrpcChannel.ForAddress(baseUri, new GrpcChannelOptions() { HttpClient = httpClient });
        

        });

        【讨论】:

          【解决方案5】:

          对于我的解决方案,我提取了代码以在单独的类中获取和缓存令牌:GrpcBearerTokenProvider.cs

          public class GrpcBearerTokenProvider
          {
              private readonly IAccessTokenProvider _provider;
              private readonly NavigationManager _navigation;
              private AccessToken _lastToken;
              private string _cachedToken;
          
              public GrpcBearerTokenProvider(IAccessTokenProvider provider, NavigationManager navigation)
              {
                  _provider = provider;
                  _navigation = navigation;
              }
          
              public async Task<string> GetTokenAsync(params string[] scopes)
              {
                  var now = DateTimeOffset.Now;
          
                  if (_lastToken == null || now >= _lastToken.Expires.AddMinutes(-5))
                  {
                      var tokenResult = scopes?.Length > 0 ?
                          await _provider.RequestAccessToken(new AccessTokenRequestOptions { Scopes = scopes }) :
                          await _provider.RequestAccessToken();
          
                      if (tokenResult.TryGetToken(out var token))
                      {
                          _lastToken = token;
                          _cachedToken = _lastToken.Value;
                      }
                      else
                      {
                          throw new AccessTokenNotAvailableException(_navigation, tokenResult, scopes);
                      }
                  }
          
                  return _cachedToken;
              }
          }
          

          可以在部分页面代码隐藏中使用如下:

          [Inject]
          public GrpcChannel Channel { get; set; }
          
          [Inject]
          public GrpcBearerTokenProvider GrpcBearerTokenProvider { get; set; }
          
          private async Task IncrementCount()
          {
              var cts = new CancellationTokenSource();
          
              string token = "";
              try
              {
                  token = await GrpcBearerTokenProvider.GetTokenAsync(Program.Scope);
              }
              catch (AccessTokenNotAvailableException a)
              {
                  a.Redirect();
              }
          
              var headers = new Metadata
              {
                  { "Authorization", $"Bearer {token}" }
              };
          
              var client = new Count.Counter.CounterClient(Channel);
              var call = client.StartCounter(new CounterRequest { Start = currentCount }, headers, cancellationToken: cts.Token);
          }
          

          完整的示例项目可以在这里找到:

          【讨论】:

            【解决方案6】:

            我在部署具有不同主机名的单独 API/Identity/gRPC 服务器和 Blazor WASM/gRPC 客户端时遇到了这个确切的问题。发送到 gRPC 服务器的请求不包括 authorization 标头,因此即使用户已成功通过身份验证,也是 gRPC 401/Unauthenticated

            如果您使用的是 IdentityServer4(或任何身份验证),并且它是从与 Blazor WASM 应用程序不同的端点 (URI) 托管的,则您将需要 AuthorizationMessageHandler 的自定义实现。首先,在ConfigureHandler() 中设置authorizedUrls 以包含您的后端服务器,然后更新您的Program.cs 文件并将新创建的消息处理程序替换或添加到gRPC 和Http 客户端。

            很简单,创建自定义类实现:

            public class CorsAuthorizationMessageHandler : AuthorizationMessageHandler
            {
                public CorsAuthorizationMessageHandler(IAccessTokenProvider provider, 
                   NavigationManager navigation) : base(provider, navigation)
                {
                    ConfigureHandler(authorizedUrls: new[] { "https://api.myapp.com" });
                }
            }
            

            然后更新 Progam.cs 并添加以下范围服务:

            builder.Services.AddScoped<CorsAuthorizationMessageHandler>();
            

            接下来更新任何安全的HttpClients

            builder.Services.AddHttpClient(
                "Private.ServerAPI", 
                client => client.BaseAddress = new Uri("https://api.myapp.com")
            ).AddHttpMessageHandler<CorsAuthorizationMessageHandler>();
            

            最后是gRPC 客户:

            builder.Services.AddScoped(sp =>
            {
                var messageHandler = sp.GetRequiredService<CorsAuthorizationMessageHandler>();
                messageHandler.InnerHandler = new HttpClientHandler();
                var grpcWebHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, messageHandler);
                var channel = GrpcChannel.ForAddress("https://api.myapp.com", 
                    new GrpcChannelOptions { HttpHandler = grpcWebHandler });
                return new MygRPCService.MygRPCServiceClient(channel);
            });
            

            就是这样!如果您对此配置有任何疑问,请告诉我。

            【讨论】:

            • 谢谢,不需要 CorsAuthorizationMessageHandler 但您的回答让我弄清楚了如何设置 gRPC 客户端以使用 BaseAddressAuthorizationMessageHandler。需要使用 AddScoped 而不是 AddSingleton 以及正确设置消息处理程序。
            猜你喜欢
            • 2019-03-08
            • 2020-11-02
            • 1970-01-01
            • 2021-09-16
            • 2022-01-21
            • 2020-09-16
            • 1970-01-01
            • 2015-01-01
            • 1970-01-01
            相关资源
            最近更新 更多