Blazor Webassembly通过依赖注入进行gRPC-Web通道身份验证

时间:2020-04-10 18:48:04

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

我正在使用身份验证在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通道?

6 个答案:

答案 0 :(得分:3)

经过大量其他测试,我找到了解决方案。 虽然还不完美,但到目前为止效果很好。

启动期间通道的注册

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

答案 1 :(得分:1)

您可以对此进行一些更改并跳过异步。它并不漂亮,但是您摆脱了任务频道。我没有尝试过下面的代码,而只是考虑如何实现。

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 });

});

答案 2 :(得分:1)

我基于.NET Core 3.2中Microsoft的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;
    }
}

这是背后的理由。

根据史蒂夫·桑德森的this blog post,只需要将GrpcWebHandler添加到HttpClient即可使用GrpcWeb。但是,如果您尝试将BaseAddressAuthorizationMessageHandler与GrpcWebHandler一起使用,则当用户未经身份验证时,您将获得RpcException,其状态代码为StatusInternal。

查看代码后,我发现导致异常的原因是授权处理程序在令牌不可用时引发异常,而GrpcWebHandler将其捕获为内部异常。如果您添加一个不引发该异常的自定义消息处理程序,例如上面的处理程序,则GrpcWebHandler将引发StatusCode = Unauthenticated的正确RcpException,然后您可以相应地进行处理,例如,通过重定向到登录页面。

这是如何在无需添加其他授权代码的情况下在剃须刀页面中使用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 :(得分:1)

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

 \d+.

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

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;
    }
}

可以在此处找到完整的示例项目:

答案 4 :(得分:0)

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

如果您使用的是 IdentityServer4(或任何真正的身份验证)并且它是从与 Blazor WASM 应用不同的端点 (URI) 托管的,则您将需要 AuthorizationMessageHandler 的自定义实现。首先,在 authorizedUrls 中设置 ConfigureHandler() 以包含您的后端服务器,然后更新您的 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);
});

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

答案 5 :(得分:-1)

我尝试使用https://github.com/grpc/grpc-dotnet/tree/master/examples#ticketer中JamesNK的“ Ticketer”示例中的示例代码在Blazor WASM应用中执行类似的操作,并且该示例可以正常工作。

售票员显示如何将gRPC与身份验证和 ASP.NET Core中的授权。此示例具有一个标记为gRPC的方法 具有[Authorize]属性。客户端只能在以下情况下调用该方法 它已经由服务器验证并传递了有效的JWT令牌 用gRPC调用。

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