На главную

Сертификат HttpClient в Azure Key Vault

29-01-2019

О чём речь

При работе с внешними REST API мы применяем HttpClient. Чтобы построить маршрут с помощью Google Maps Directions API мы пишем код, похожий на этот:

public class GoogleMapsDirectionsClient
{
    private const string _baseUri = "https://maps.googleapis.com/maps/api/directions/json";

    private readonly string _apiKey;

    public GoogleMapsDirectionsClient(string apiKey)
    {
        _apiKey = apiKey;
    }

    public async Task<Route> BuildRouteAsync(Location from, Location to)
    {
        var parameters = new Dictionary<string, string>
        {
            { "key", _apiKey },
            { "origin", string.Format(CultureInfo.InvariantCulture, "{0},{1}", from.Latitude, from.Longitude) },
            { "destination", string.Format(CultureInfo.InvariantCulture, "{0},{1}", to.Latitude, to.Longitude) },
        };

        var uri = BuildUri(parameters);

        using (var httpClient = new HttpClient())
        {
            var httpResponseMessage = await httpClient.GetAsync(uri);

            httpResponseMessage.EnsureSuccessStatusCode();

            var json = await httpResponseMessage.Content.ReadAsStringAsync();

            return JsonConvert.Deserialize<Route>(json);
        }
    }

    private static Uri BuildUri(IReadOnlyDictionary<string, string> queryStringParameters)
    {
        var builder = new UriBuilder(_baseUri);
        builder.Query = BuildQueryString(queryStringParameters);

        return builder.Uri;
    }

    private static string BuildQueryString(IReadOnlyDictionary<string, string> queryStringParameters)
    {
        var assigns = queryStringParameters.Select(x => x.Key + "=" + x.Value);

        return string.Join("&", assigns);
    }
}

Код простой, поскольку Google Maps Directions API не требует параноидальной защиты. Но когда дело касается денег, параноидальная защита становится нужна. Одним из средств такой защиты является клиентский сертификат.

Вы заключаете договор с финансовой компанией, например с такой, которая принимает оплату для вашего интернет-магазина. Компания выдаёт вам сертификат и пароль к нему.

Сертификаты могут храниться в разных форматах. Azure поддерживает только формат PFX. Если финансовая компания выдала сертификат в другом формате, его нужно перекодировать с помощью утилиты OpenSSL.

Вот так выглядит подключение сертификата из файла:

public HttpClient CreateHttpClient()
{
    var certificate = new X509Certificate2(filename, password);
    var handler = new HttpClientHandler
    {
        ClientCertificates = { certificate }
    }

    return new HttpClient(handler);
}

Хранение сертификата вместе с программой не является хорошей идеей. Он является средством проверки подлинности, и его нельзя разбрасывать где попало.

Куда же положить сертификат, если наше клиентское приложение живёт в Azure?

Импорт сертификата в Хранилище ключей

В Azure для защищённого хранения сертификатов используют Key Vault, то есть Хранилище ключей.

Чтобы добавить сертификат в хранилище, откроем портал Azure, а в нём — Key Vault. В панели слева мы должны увидеть вкладку Certificates.

Список сертификатов

На вкладке в верхней панели нажмём Generate/Import. Укажем метод Import, загрузим сертификат, дадим ему имя и введём пароль.

Импорт сертификата

Мы увидим сертификат в списке. Откроем:

Новый сертификат

Дважды щёлкнем на записи Current Version, чтобы посмотреть параметры сертификата.

Параметры нового сертификата

Нам понадобится параметр Secret Identifier, поэтому сохраним его в текстовом файле.

Регистрация приложения

Доступ к Хранилищу должен быть предоставлен нашему приложению, а для этого его надо зарегистрировать в Azure Active Directory. Наберём Azure Active Directory в строке поиска в самом верху Портала:

Azure Active Directory

В панели слева выберем закладку App registrations:

Список зарегистрированных приложений

Нажмём кнопку New application registration и введём параметры приложения:

Регистрация приложения

Не имеет значения, что мы введём в Sign-on URL. Главное, сохраним поле Name — оно нам понадобится на следующем шаге. Добавив приложение, мы не увидим его в списке, поскольку установлен фильтр My apps. Переключимся на All apps, найдём своё приложение и откроем его.

Параметры нового приложения

Нам потребуется параметр, который называется Application ID. Запишем и нажмём кнопку Settings в заголовке. Увидим панель с настройками:

Дополнительные параметры нового приложения

Нам нужна вкладка Keys. Как видите, мы можем создать здесь пароли (Passwords) и публичные ключи (Public Keys). Нам нужен новый пароль. Введём описание, выберем время жизни пароля и нажмём кнопку Save наверху:

Создание пароля

Здесь будьте осторожны. Портал показывает вам значение созданного пароля, чтобы вы могли его скопировать и сохранить. Сейчас самое время это сделать, потому что потом увидеть пароль в Портале будет нельзя. Об этом и предупреждение вверху экрана.

Значение пароля

Политика доступа

Мы на предпоследнем шаге. Вернёмся в Хранилище ключей. В левой панели откроем вкладку Access policies:

Список политик доступа

Нажмём кнопку Add new. Выберем шаблон Key, Secret, & Certificate Management. В поле Select principal выберем приложение, которое мы зарегистрировали на предыдущем шаге. Я просил вас запомнить его название, сейчас оно пригодится.

Список политик доступа

В поле Authorized application оставим существующее значение None selected. Нажмём кнопку Ok и вернёмся к список политик доступа. Очень важно нажать кнопку Save в верхней части панели.

Мы готовы к коду

Сейчас у вас на руках должны быть Secret Identifier, Application ID и пароль, созданный для доступа к нашему приложению.

Мы готовы к тому, чтобы запрограммировать загрузку сертификата из Хранилища и подключить его к HttpClient. Предположим, за обращение к финансовому API в нашем приложении отвечает гипотетический класс FinanceClient. Я добавил в него гипотетический метод GetCurrentWalletAsync, который получает из внешнего API кошелёк пользователя.

Доступ идёт по каналу, защищённому клиентским сертификатом.

public class FinanceClient
{
    private readonly string _secretIdentifier;
    private readonly string _applicationId;
    private readonly string _password;

    public FinanceClient(string secretIdentifer, string applicationId, string password)
    {
        _secretIdentifier = secretIdentifier;
        _applicationId = applicationId;
        _password = password;
    }

    private async Task<HttpClient> CreateHttpClientAsync()
    {
        // https://docs.microsoft.com/ru-ru/azure/key-vault/key-vault-use-from-web-application
        // https://stackoverflow.com/questions/37033073/how-can-i-create-an-x509certificate2-object-from-an-azure-key-vault-keybundle
        using (var keyVaultClient = new KeyVaultClient(GetToken))
        {
            var secret = await keyVaultClient.GetSecretAsync(_secretIdentifier);
            var bytes = Convert.FromBase64String(secret.Value);
            var certificate = new X509Certificate2(bytes, (string)null, X509KeyStorageFlags.MachineKeySet);
            var handler = new HttpClientHandler
            {
                ClientCertificates = { certificate },
            };

            return new HttpClient(handler);
        }
    }

    private async Task<string> GetToken(string authority, string resource, string scope)
    {
        var authenticationContext = new AuthenticationContext(authority);
        ClientCredential clientCred = new ClientCredential(_applicationId, _password);
        AuthenticationResult result = await authenticationContext.AcquireTokenAsync(resource, clientCred);

        if (result == null)
            throw new InvalidOperationException("Failed to obtain the JWT token");

        return result.AccessToken;
    }

    public async Task<Wallet> GetCurrentWalletAsync()
    {
        using (var httpClient = await CreateHttpClientAsync())
        {
            var httpResponseMessage = await httpClient.GetAsync("http://api.some-finance.tld/wallets/current");

            httpResponseMessage.EnsureSuccessStatusCode();

            var json = await httpResponseMessage.Content.ReadAsString();

            return JsonConvert.Deserialize<Wallet>(json);
        }
    }
}

Как видим, подключиться к Хранилищу непросто. Я ожидал, что это будет не сложнее, чем подлюкчение к SQL серверу, но на деле код пришлось разбить на два метода. В методе CreateHttpClientAsync вызывается конструктор KeyVaultClient, которому мы передаём обработчик для получения токена GetToken. Я и сам не понимаю, почему здесь надо делать именно так — документация не изобилует подробностями.

В любом случае, в январе 2019-го года этот код работает. Чтобы разобраться, я потратил неделю времени, потому что информацию пришлось собирать по крупицам с десятка ресурсов.

Оптимизация

В этом коде есть две проблемы. Первая заключается в том, что в .NET Core 2 создавать HttpClient вручную считается моветоном. Вместо этого надо через DI-контейнер получать IHttpClientFactory и вызывать метод CreateClient.

Чтобы это работало, зарегистрируем клиент с уникальным именем. В классе Startup напишем:

services.AddHttpClient("signed")
        .ConfigurePrimaryHttpMessageHandler(() => 
        {
            using (var keyVaultClient = new KeyVaultClient(GetToken))
            {
                var secretIdentifier = Configuration.GetValue("CERTIFICATE_SECRET_IDENTIFIER", "");
                var secret = keyVaultClient.GetSecretAsync(secretIdentifier)
                                           .Result;
                var bytes = Convert.FromBase64String(secret.Value);
                var certificate = new X509Certificate2(bytes, (string)null, X509KeyStorageFlags.MachineKeySet);
                
                return new HttpClientHandler
                {
                    ClientCertificates = { certificate },
                };
            }
        });

Метод GetToken надо разместить в том же классе Startup. Он должен выглядеть так:

private async Task<string> GetToken(string authority, string resource, string scope)
{
    var authenticationContext = new AuthenticationContext(authority);

    var applicationId = Configuration.GetValue("CERTIFICATE_APPLICATION_ID", "");
    var password = Configuration.GetValue("CERTIFICATE_PASSWORD", "");

    ClientCredential clientCred = new ClientCredential(applicationId, password);
    AuthenticationResult result = await authenticationContext.AcquireTokenAsync(resource, clientCred);

    if (result == null)
        throw new InvalidOperationException("Failed to obtain the JWT token");

    return result.AccessToken;
}

Мы обращаемся к конфигурации приложения, чтобы загрузить значения Secret Identifer, Application ID и пароль.

Проблема этого кода в том, что он синхронно вызывается при старте нашего приложения, а загрузить certificate через KeyVaultClient мы можем только асинхронно. Поэтому нам приходится приоставноить загрузку, чтобы дождаться выполнения асинхронной операции GetSecretAsync.

Это не очень здорово, но найти хорошего обходного решения я не смог. Если знаете, напишите.

Реализация IHttpClientFactory решает проблему с производительностью, поскольку кэширует экземпляры HttpClientHandler. Если мы откажемся от фабрики в данном сценарии, мы можем сами кэшировать обработчик и повысим производительность. Для этого воспользуемся ленивой инициализацией.

public class FinanceClient : IDisposable
{
    private readonly string _secretIdentifier;
    private readonly string _applicationId;
    private readonly string _password;
    private readonly Lazy<Task<HttpClientHandler>> _lazyHandler;

    public FinanceClient(string secretIdentifer, string applicationId, string password)
    {
        _secretIdentifier = secretIdentifier;
        _applicationId = applicationId;
        _password = password;
        _lazyHandler = new Lazy<Task<HttpClientHandler>>(CreateHttpClientHandlerAsync);
    }

    protected async Task<HttpClientHandler> CreateHttpClientHandlerAsync()
    {
        using (var keyVaultClient = new KeyVaultClient(GetToken))
        {
            var secret = await keyVaultClient.GetSecretAsync(secretIdentifier);
            var bytes = Convert.FromBase64String(secret.Value);
            var certificate = new X509Certificate2(bytes, (string)null, X509KeyStorageFlags.MachineKeySet);
            return new HttpClientHandler
            {
                ClientCertificates = { certificate },
            };
        }
    }

    protected async Task<HttpClient> CreateHttpClientAsync()
    {
        var handler = await lazyHandler.Value;

        return new HttpClient(handler, disposeHandler: false);
    }
    . . .
    public void Dispose()
    {
        if (_lazyHandler.IsValueCreated)
            _lazyHandler.Value.Dispose();
    }
}

Код метода CreateHttpClientAsync упростился, благодаря тому, что существенная его часть перебралась в CreateHttpClientHandlerAsync. Нам приходится реализовать IDisposable потому что HttpClient теперь не освобождает HttpClientHandler.

Этот код не «подвисает» на старте приложения и работает быстро, но у него тоже есть проблема. Одной из причин появления фабрики IHttpClientFactory была ошибка в коде HttpClientHandler, из-за которой запросы к DNS кэшировались и не обновлялись. Чтобы с этим справиться, «встроенная» реализация DefaultHttpClientFactory пересоздаёт обработчики один раз в несколько минут.

Мы можем сделать то же самое в нашем коде. Он станет сложнее, но будет стабильнее работать с сервисами, у которых DNS-записи регулярно обновляются.

Если это кажется слишком сложным, можно вернуться к самому первому варианту, где сертификат загружается при каждом обращении к внешней системе. Мы предположили, что это будет очень медленно, но серьёзных оснований для такого предположения у нас нет.

Так что мы можем начать с простой реализации, провести нагрузочное тестирование, и принять решение на основании реальных данных.