Сертификат 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 в строке поиска в самом верху Портала:
В панели слева выберем закладку 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-записи регулярно обновляются.
Если это кажется слишком сложным, можно вернуться к самому первому варианту, где сертификат загружается при каждом обращении к внешней системе. Мы предположили, что это будет очень медленно, но серьёзных оснований для такого предположения у нас нет.
Так что мы можем начать с простой реализации, провести нагрузочное тестирование, и принять решение на основании реальных данных.