.net services.AddHttpClient 自动访问令牌处理

.net services.AddHttpClient Automatic Access Token Handling

我正在尝试编写一个 Blazor 应用程序,它使用客户端机密凭据来获取 API 的访问令牌。我想以这样一种方式封装它,让它在幕后处理令牌获取和刷新。为此,我创建了以下使用 IdentityModel Nuget 包的继承 class:

public class MPSHttpClient : HttpClient
{
    private readonly IConfiguration Configuration;
    private readonly TokenProvider Tokens;
    private readonly ILogger Logger;

    public MPSHttpClient(IConfiguration configuration, TokenProvider tokens, ILogger logger)
    {
        Configuration = configuration;
        Tokens = tokens;
        Logger = logger;
    }

    public async Task<bool> RefreshTokens()
    {
        if (Tokens.RefreshToken == null)
            return false;

        var client = new HttpClient();

        var disco = await client.GetDiscoveryDocumentAsync(Configuration["Settings:Authority"]);
        if (disco.IsError) throw new Exception(disco.Error);

        var result = await client.RequestRefreshTokenAsync(new RefreshTokenRequest
        {
            Address = disco.TokenEndpoint,
            ClientId = Configuration["Settings:ClientID"],
            RefreshToken = Tokens.RefreshToken
        });

        Logger.LogInformation("Refresh Token Result {0}", result.IsError);

        if (result.IsError)
        {
            Logger.LogError("Error: {0)", result.ErrorDescription);

            return false;
        }

        Tokens.RefreshToken = result.RefreshToken;
        Tokens.AccessToken = result.AccessToken;

        Logger.LogInformation("Access Token: {0}", result.AccessToken);
        Logger.LogInformation("Refresh Token: {0}" , result.RefreshToken);

        return true;
    }

    public async Task<bool> CheckTokens()
    {
        if (await RefreshTokens())
            return true;


        var client = new HttpClient();

        var disco = await client.GetDiscoveryDocumentAsync(Configuration["Settings:Authority"]);
        if (disco.IsError) throw new Exception(disco.Error);

        var result = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
        {
            Address = disco.TokenEndpoint,
            ClientId = Configuration["Settings:ClientID"],
            ClientSecret = Configuration["Settings:ClientSecret"]
        });

        if (result.IsError)
        {
            //Log("Error: " + result.Error);
            return false;
        }

        Tokens.AccessToken = result.AccessToken;
        Tokens.RefreshToken = result.RefreshToken;

        return true;
    }


    public new async Task<HttpResponseMessage> GetAsync(string requestUri)
    {
        DefaultRequestHeaders.Authorization =
                        new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", Tokens.AccessToken);

        var response = await base.GetAsync(requestUri);

        if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
        {
            if (await CheckTokens())
            {
                DefaultRequestHeaders.Authorization =
                                new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", Tokens.AccessToken);

                response = await base.GetAsync(requestUri);
            }
        }

        return response;
    }
}

这个想法是为了避免必须编写一堆冗余代码来尝试 API,然后 request/refresh 如果您未经授权则尝试令牌。我首先尝试使用 HttpClient 的扩展方法,但是没有好的方法将配置注入静态 class.

所以我的服务代码是这样写的:

public interface IEngineListService
{
    Task<IEnumerable<EngineList>> GetEngineList();
}

public class EngineListService : IEngineListService
{
    private readonly MPSHttpClient _httpClient;

    public EngineListService(MPSHttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    async Task<IEnumerable<EngineList>> IEngineListService.GetEngineList()
    {
        return await JsonSerializer.DeserializeAsync<IEnumerable<EngineList>>
            (await _httpClient.GetStreamAsync($"api/EngineLists"), new JsonSerializerOptions() { PropertyNameCaseInsensitive = true });
    }
}

一切都编译得很好。在我的 Startup 中,我有以下代码:

        services.AddScoped<TokenProvider>();

        services.AddHttpClient<IEngineListService, EngineListService>(client =>
        {
            client.BaseAddress = new Uri(Configuration["Settings:ApiAddress"]);
        });

为了完整起见,令牌提供程序如下所示:

public class TokenProvider
{
    public string AccessToken { get; set; }
    public string RefreshToken { get; set; }
}

当我运行 App 时,它抱怨说在services.AddHttpClient 的调用中找不到适合EngineListService 的构造函数。有没有办法将 IEngineListService 的实际实例传递给 AddHttpClient。还有其他方法可以实现吗?

谢谢, 吉姆

我认为 EngineListService 不应在服务中注册为 HttpClient,您应该注册 MPSHttpClient.

这遵循文档中的“Typed Client”示例并在幕后使用 IHttpClientFactory

https://docs.microsoft.com/en-us/aspnet/core/fundamentals/http-requests#typed-clients

当您使用 services.AddHttpClient 时,构造函数需要一个 HttpClient 参数。这就是 HttpClientFactory 初始化 HttpClient 的方式,然后将其传递到您准备就绪的服务中。

您可以将 MPSHttpClient 更改为不继承 HttpClient,而是向构造函数添加一个 HttpClient 参数。你也可以让它实现一个像 IMPSHttpClient

这样的接口
public class MPSHttpClient
{
    public MPSHttpClient(HttpClient httpClient, IConfiguration configuration, TokenProvider tokens, ILogger logger)
    {
        HttpClient = httpClient;
        Configuration = configuration;
        Tokens = tokens;
        Logger = logger;
    }
}

您必须从 MPSHttpClient 中删除这些行并使用注入的客户端。

// remove this
var client = new HttpClient();

在启动中添加

services.AddHttpClient<MPSHttpClient>(client =>
{
    // add any configuration 
    client.BaseAddress = new Uri(Configuration["Settings:ApiAddress"]);
});

将 EngineListService 更改为普通服务注册,因为它不是 HttpClient

services.AddScoped<IEngineListService, EngineListService>()

特别感谢@pinkfloydx33 帮我解决了这个问题。他分享 https://blog.joaograssi.com/typed-httpclient-with-messagehandler-getting-accesstokens-from-identityserver/ 的这个 link 是我需要的一切。诀窍是存在一个名为 DelegatingHandler 的 class,您可以继承并覆盖 OnSendAsync 方法,并在将其发送到最终的 HttpHandler 之前在那里进行所有令牌检查。所以我的新 MPSHttpClient class 是这样的:

public class MPSHttpClient : DelegatingHandler
{
    private readonly IConfiguration Configuration;
    private readonly TokenProvider Tokens;
    private readonly ILogger<MPSHttpClient> Logger;
    private readonly HttpClient client;

    public MPSHttpClient(HttpClient httpClient, IConfiguration configuration, TokenProvider tokens, ILogger<MPSHttpClient> logger)
    {
        Configuration = configuration;
        Tokens = tokens;
        Logger = logger;
        client = httpClient;
    }

    public async Task<bool> CheckTokens()
    {
        var disco = await client.GetDiscoveryDocumentAsync(Configuration["Settings:Authority"]);
        if (disco.IsError) throw new Exception(disco.Error);

        var result = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
        {
            Address = disco.TokenEndpoint,
            ClientId = Configuration["Settings:ClientID"],
            ClientSecret = Configuration["Settings:ClientSecret"]
        });

        if (result.IsError)
        {
            //Log("Error: " + result.Error);
            return false;
        }

        Tokens.AccessToken = result.AccessToken;
        Tokens.RefreshToken = result.RefreshToken;

        return true;
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        request.SetBearerToken(Tokens.AccessToken);

        var response = await base.SendAsync(request, cancellationToken);

        if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
        {
            if (await CheckTokens())
            {
                request.SetBearerToken(Tokens.AccessToken);

                response = await base.SendAsync(request, cancellationToken);
            }
        }

        return response;
    }
}

这里最大的变化是继承,我使用 DI 来获取 HttpClient,就像@Rosco 提到的那样。我曾尝试覆盖原始版本中的 OnGetAsync。从 DelegatingHandler 继承时,您只需覆盖 OnSendAsync。这将在一个方法中处理所有从 HttpContext 中获取、放置、post 和删除的操作。

我的 EngineList 服务写得好像没有要考虑的标记,这是我最初的目标:

public interface IEngineListService
{
    Task<IEnumerable<EngineList>> GetEngineList();
}

public class EngineListService : IEngineListService
{
    private readonly HttpClient _httpClient;

    public EngineListService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    async Task<IEnumerable<EngineList>> IEngineListService.GetEngineList()
    {
        return await JsonSerializer.DeserializeAsync<IEnumerable<EngineList>>
            (await _httpClient.GetStreamAsync($"api/EngineLists"), new JsonSerializerOptions() { PropertyNameCaseInsensitive = true });
    }
}

令牌提供者保持不变。我计划为其添加过期等,但它按原样工作:

public class TokenProvider
{
    public string AccessToken { get; set; }
    public string RefreshToken { get; set; }
}

ConfigureServices 代码稍作改动:

    public void ConfigureServices(IServiceCollection services)
    {
    ...

        services.AddScoped<TokenProvider>();

        services.AddTransient<MPSHttpClient>();

        services.AddHttpClient<IEngineListService, EngineListService>(client =>
        {
            client.BaseAddress = new Uri(Configuration["Settings:ApiAddress"]);
        }).AddHttpMessageHandler<MPSHttpClient>();

     ...
     }

您将 MPSHttpClient 实例化为 Transient,然后使用附加到 AddHttpClient 调用的 AddHttpMessageHandler 调用引用它。我知道这与其他人实现 HttpClients 的方式不同,但我从 Pluralsight 视频中学习了这种创建客户端服务的方法,并一直将其用于所有事情。我为数据库中的每个实体创建了一个单独的服务。如果说我想做轮胎,我会在 ConfigureServices 中添加以下内容:

        services.AddHttpClient<ITireListService, TireListService>(client =>
        {
            client.BaseAddress = new Uri(Configuration["Settings:ApiAddress"]);
        }).AddHttpMessageHandler<MPSHttpClient>();

它将使用相同的 DelegatingHandler,因此我可以继续为每个实体类型添加服务,而不再担心令牌。感谢所有回复的人。

谢谢, 吉姆