IdentityServer, HttpClient и всё такое
10-10-2019
Давно надо было опробовать IdentityServer4, который Microsoft выпустила три года назад, в декабре 2016-го.
IdentityServer4 — это полуфабрикат, из которого легко собрать работающий сервер аутентификации за двадцать минут. Если вы знаете, как его собирать.
Он реализует стандарты OpenID Connect и OAuth 2.0, он стыкуется с Google, Facebook и Azure Active Directory. Если вы разрабатываете Web API, берите IdentityServer4 для аутентификации — не ошибётесь.
Так я думал вначале недели. Сегодня четверг, и я у меня только что всё заработало. Почему так долго?
Радужное начало
В интернете есть документация, с помощью которой можно быстро разработать серверный и клиентский код аутентификации.
Кратко процесс выглядит так: создаём ASP.NET Core приложение, устанавливаем пакет IdentityServer4, подключаем готовые модули, конфигурируем и — наслаждаемся работающей аутентификацией.
Первый сценарий, о котором рассказывает руководство — это Client Credentials. В этом сценарии нет даже пользователей, он применяется, когда два независимых сервиса должны безопасно работать вместе.
Решение из этой главы состоит из трёх проектов — IdentityServer, Api и Client. IdentityServer — это приложение Web API, которое отвечает за аутентификацию и умеет создавать токены. Api — веб-приложение, которое отдаёт инофрмацию только доверенному клиенту. Client — консольное приложение, тот самый клиент.
В терминах стандартов OpenID и OAuth Клиент — это программа, а не пользователь.
Вначале клиент должен подключиться к серверу аутентификации и получить токен доступа. Код в статье выглядит так:
var client = new HttpClient();
var disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000");
if (disco.IsError)
{
Console.WriteLine(disco.Error);
return;
}
// request token
var tokenResponse = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{
Address = disco.TokenEndpoint,
ClientId = "console",
ClientSecret = "secret",
Scope = "api1"
});
И он не работет. После вызова GetDiscoveryDocumentAsync
объект disco
содержит сообщение об ошибке: Error connecting to http://localhost:5000/.well-known/openid-configuration: Service Unavailable.
Если я захожу по адресу http://localhost:5000/.well-known/openid-configuration, то вижу, что сервис работает и возвращает мне JSON.
{
"issuer":"http://localhost:5000",
"jwks_uri":"http://localhost:5000/.well-known/openid-configuration/jwks",
"authorization_endpoint":"http://localhost:5000/connect/authorize",
"token_endpoint":"http://localhost:5000/connect/token",
"userinfo_endpoint":"http://localhost:5000/connect/userinfo",
"end_session_endpoint":"http://localhost:5000/connect/endsession",
"check_session_iframe":"http://localhost:5000/connect/checksession",
"revocation_endpoint":"http://localhost:5000/connect/revocation",
"introspection_endpoint":"http://localhost:5000/connect/introspect",
"device_authorization_endpoint":"http://localhost:5000/connect/deviceauthorization",
"frontchannel_logout_supported":true,
"frontchannel_logout_session_supported":true,
"backchannel_logout_supported":true,
"backchannel_logout_session_supported":true,
"scopes_supported": [ "openid", "api1","offline_access" ],
"claims_supported": [ "sub" ],
"grant_types_supported": [ "authorization_code", "client_credentials", "refresh_token", "implicit", "urn:ietf:params:oauth:grant-type:device_code" ],
"response_types_supported": [ "code", "token", "id_token", "id_token token", "code id_token", "code token", "code id_token token" ],
"response_modes_supported": [ "form_post", "query", "fragment" ],
"token_endpoint_auth_methods_supported": [ "client_secret_basic", "client_secret_post" ],
"id_token_signing_alg_values_supported": [ "RS256" ],
"subject_types_supported": [ "public" ],
"code_challenge_methods_supported": [ "plain","S256" ],
"request_parameter_supported":true
}
Почему же не работает консольная программа?
Первая попытка
К счастью, объект disco
содержит не только поля, возвращаемые сервером, но и данные HTTP-ответа. А HTTP-ответ в .NET ссылается на HTTP-запрос, так что мы можем попытаться понять, что у нас не так.
Поле HttpResponse.RequestMessage.RequestUri
имеет значение http://192.168.7.24/. И, кажется, этот адрес совершенно не похож на http://localhost:5000, который мы видим в коде. 192.168.7.24 — IP-адрес моей машины, но почему вдруг localhost разрешается в него, а не в 127.0.0.1 и куда пропал порт 5000?
Сначала я решил проверить, а прослушивает ли IdentityServer внешний порт. Оказалось, что нет, потому что в настройках явно был указан хост http://localhost:5000.
launchSettings.json
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:5000",
"sslPort": 0
}
},
"profiles": {
"IIS Express": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"SelfHost": {
"commandName": "Executable",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:5000",
"executablePath": "IdentityServer.exe"
}
}
Чтобы сервер прослушивал все интерфейсы IPv4, в качестве applicaitonUri
надо указать http://0.0.0.0:5000, а чтобы все интерфейсы IPv4 и IPv6 — http://*:5000.
Но сервер продолжал быть недоступным. Я решил, что, возможно, дело в файрволе и открыл порт 5000 на внешнем интерфейсе, вызвав команду netsh
.
netsh advfirewall firewall add rule name="HTTP 5000" dir=in action=allow protocol=TCP localport=5000
Chrome показывал мне правильный JSON, а клиент продолжал возвращать ошибку 503 Service Unavailable.
Вторая попытка
Я предположил, что дело в классе HttpClient
, который осуществляет запрос к IdentityServer из приложения Client. На каком-то этапе он искажает URI, который я ему передал, и мне надо всего лишь выяснить, почему.
Обычно я неплох в Google. Однако, наступил момент, когда весь мой опыт мне не помог. Я придумывал десятки формулировок, пытаясь описать проблему разными способами, но интернет был безмолвен. HttpClient
не изменяет переданный ему URI. Точка.
Тогда почему изменяет у меня? Что я сделал не так?
Прозрение
Теперь я знаю, что я сделал не так. Я поменял работу. На старой работе у нас не было файрвола, мы сидели в интернете напрямую. Теперь я работаю в банке и файрвол у меня есть. Его параметры настроены в Windows и Chrome их использует. А HttpClient
из коробки — нет.
Чтобы включить прокси, надо добавить к HttpClient
обработчик с настроенными параметрами.
Client\Program.cs
var httpHandler = new HttpClientHandler
{
Proxy = new WebProxy("192.168.7.100:8080")
{
BypassProxyOnLocal = true,
UseDefaultCredentials = true,
},
};
var client = new HttpClient(handler: httpHandler, disposeHandler: true);
Для обращения к серверу Api консольная программа Client использует ещё один экземпляр HttpClient
, который называется apiClient
. Его тоже надо проинициализировать нашим обработчиком.
var apiClient = new HttpClient(handler: httpHandler, disposeHandler: true);
Наконец, сервер Api сам обращается к IdentityServer, чтобы узнать, можно ли доверять клиенту. Это другой проект, поэтому мы должны продублировать код обработчика.
Api\Startupcs
services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "http://localhost:5000/";
options.RequireHttpsMetadata = false;
options.Audience = "api1";
options.BackchannelHttpHandler = new HttpClientHandler
{
Proxy = new WebProxy("192.168.7.100:8080")
{
BypassProxyOnLocal = true,
UseDefaultCredentials = true,
},
};
});
Вот теперь консольное приложение показывает мне именно то, что я жду.
Заключение
Как я догадался, что дело в файрволе? Я не помню. Мне бы хотелось поделиться секретом решения нетривиальных задач, но как и в других случаях, я его не знаю. Я просто перебирал в голове разные варианты, пока не наткнулся на очевидный. Я его проверил, и он сработал.
Такова работа программиста: делаешь что-то и надеешься, что рано или поздно что-нибудь получится. Странный вывод через тридцать лет работы программистом, не правда ли?