3
votes

Authentification du canal gRPC-Web avec Blazor Webassembly via l'injection de dépendances

Je teste gRPC-Web dans Blazor Webassembly avec authentification et frappe un petit peu sur la façon d'obtenir un accès propre à mon canal gRPC.

Sans authentification, il existe un moyen assez simple et propre, comme détaillé dans l'exemple Blazor pour grpc-dotnethttps://github.com/grpc/grpc-dotnet/tree/master/examples/Blazor .

Mise à disposition du canal:

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

Utilisation dans les fichiers Razor

@inject GrpcChannel Channel

Ajouter l'authentification directement dans le fichier de rasoir et créer le canal n'est pas si compliqué non plus

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

Mais cela déplace une grande partie de la logique requise dans le fichier rasoir. Existe-t-il un moyen de les combiner et de fournir un canal grpc authentifié par injection?


0 commentaires

5 Réponses :


3
votes

Après de nombreux tests supplémentaires, j'ai trouvé une solution. Bien qu'il ne soit pas parfait, il fonctionne bien jusqu'à présent.

Enregistrement de la chaîne au démarrage

@inject Task<GrpcChannel> Channel

Puisque le canal est enregistré en utilisant async, il doit être injecté en tant que tâche

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

});


0 commentaires

-1
votes

J'ai essayé de faire quelque chose de similaire dans mon application Blazor WASM avec l'exemple de code de l'exemple 'Ticketer' de JamesNK à https://github.com/grpc/grpc-dotnet/tree/master/examples#ticketer et cela fonctionne.

Le ticketer montre comment utiliser gRPC avec authentification et autorisation dans ASP.NET Core. Cet exemple a une méthode gRPC marquée avec un attribut [Authorize]. Le client ne peut appeler la méthode que si elle a été authentifiée par le serveur et transmet un jeton JWT valide avec l'appel gRPC.

Je crée un jeton dans 'Client / Shared / NavMenu.cs' ( OnInitializedAsync() ) et j'utilise ce jeton dans les appels aux services gRPC dans d'autres pages.


0 commentaires

1
votes

Vous pouvez changer cela un peu et ignorer l'asynchrone. Ce n'est pas joli mais vous vous débarrassez du canal de tâches. Je n'ai pas essayé le code ci-dessous, c'est juste une réflexion sur la façon dont cela pourrait être fait.

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

});


0 commentaires

1
votes

J'ai résolu ce problème en me basant sur les nouveaux modèles de projet pour les projets Hosted Blazor WebAssembly de Microsoft dans .NET Core 3.2. J'ai copié le code de BaseAddressAuthorizationMessageHandler mais j'ai commenté l'exception levée lorsque le jeton n'est pas disponible et je l'ai ajouté au HttpClient dans Program.cs:

Program.cs :

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

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

Voici la justification derrière cela.

Selon ce billet de blog de Steve Sanderson, il vous suffit d'ajouter le GrpcWebHandler au HttpClient pour pouvoir utiliser GrpcWeb. Cependant, si vous essayez d'utiliser le BaseAddressAuthorizationMessageHandler avec le GrpcWebHandler, vous obtiendrez une RpcException avec StatusCode = Internal levée lorsque l'utilisateur n'est pas authentifié.

Après avoir examiné le code, j'ai trouvé que la cause de l'exception est que le gestionnaire d'autorisation lève une exception lorsque le jeton n'est pas disponible et que GrpcWebHandler le détecte comme une exception interne. Si vous ajoutez un gestionnaire de messages personnalisé qui ne lève pas cette exception, comme celui ci-dessus, le GrpcWebHandler lèvera l'exception RcpException correcte avec StatusCode = Unauthenticated, que vous pouvez ensuite gérer en conséquence, par exemple en redirigeant vers la page de connexion.

Voici un exemple de la façon dont vous pouvez ensuite utiliser votre GrpcClient dans une page rasoir sans avoir besoin d'ajouter un code d'autorisation supplémentaire:

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


0 commentaires

1
votes

Pour ma solution, j'ai extrait le code pour obtenir et mettre en cache le jeton dans une classe distincte: GrpcBearerTokenProvider.cs

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

Qui peut être utilisé dans la page partielle code-behind comme:

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

Des exemples de projets complets peuvent être trouvés ici:


0 commentaires