Аутентификация в ASP.Net (Core) — тема довольно избитая, казалось бы, о чем тут еще можно писать. Но по какой-то причине за бортом остается небольшой кусочек — сквозная доменная аутентификация (ntlm, kerberos). Да, когда мы свое приложение хостим на IIS, все понятно — он за нас делает всю работу, а мы просто получаем пользователя из контекста. А что делать, если приложение написано под .Net Core, хостится на Linux машине за Nginx, а заказчик при этом предъявляет требования к прозрачной аутентификации для доменных пользователей? Очевидно, что IIS нам тут сильно не поможет. Ниже я расскажу, как можно данную задачу решить c минимальными трудозатратами. Написанное актуально для .Net Core версии 2.0-2.2. Скорее всего, будет работать на версии 3 и с той же вероятностью не будет работать на версии 1. Делаю оговорку на версионность, поскольку .Net Core довольно активно развивается, и частенько методы, сервисы, зависимости могут менять имена, местоположение, сигнатуры или вообще пропадать.
Что такое Kerberos, и как это работает, кратко можно прочитать в Wiki. В нашей задаче Kerberos в паре с keytab файлом дает возможность приложению на Linux сервере (на Windows, само собой, тоже), который не требуется включать в домен, пропускать сквозной аутентификацией пользователей на windows-клиентах.
Большое огорчение для того читателя, который ожидал увидеть здесь код работы с самим kerberos. Увы, его не будет. Мне повезло, полчаса поиска на github и вот она, удача — библиотека Kerberos.NET (в nuget тоже есть). Проект развивается, много чего умеет. Советую изучить ее повнимательнее.
Поизучав исходники ASP.NET Core, а конкретно исходники реализаций популярных способов аутентификации, я решил делать поддержку Kerberos поверх уже реализованной Cookies аутентификации.
На мой взгляд, это один из самых простых и быстрых способов, поскольку это избавило меня от написания приличного объема инфраструктурного кода: работа непосредственно с Cookies, генерация внутреннего токена аутентификации, контроль за временем жизни сессии. И потом, нужные нам методы внезапно можно перегружать, как-будто Microsoft специально заложил возможность расширения возможности стандартной реализации своим кодом.
Процесс Kerberos аутентификации состоит из нескольких шагов:
- Обращение неаутентифицированного клиента в web-приложение
- Предварительно настроенное на поддержку Kerberos приложение получает запрос от неизвестного клиента и желает опознать его. Для этого оно в ответе на определенный метод *Web API* добавляет заголовок WWW-Authenticate со значением Negotiate
- Браузер видит заголовок WWW-Authenticate со значением Negotiate и понимает, что приложение хочет опознать пользователя по доменной сессии
- Если все настроено корректно, то на приложение уходит запрос с проставленным заголовком Authorization и значением вида Negotiate {Kerberos тикет}
- Приложение валидирует тикет и получает информацию о пользователе из домена
- Profit!
С порядком действий определились, пора писать код.
Начнем с самого главного — нужно придумать имя нашего нового способа аутентификации. Создадим класс, в котором будем хранить его константой:
public class MixedAuthenticationDefaults
{
public const string AuthenticationScheme = "Mixed";
public const string AuthorizationHeader = "Negotiate";
}
Назовем Mixed. Заодно рядышком положим в константу значение заголовка WWW-Authenticate.
Далее по плану — сообщить браузеру клиента, что мы желаем аутентифицироваться по доменной учетной записи.
Теперь неплохо бы добавить в Web API нашего приложения метод для запроса аутентификации:
[HttpGet("login")]
public async Task<IActionResult> External()
{
return Challenge(new AuthenticationProperties(), MixedAuthenticationDefaults.AuthenticationScheme);
}
Касательно использования вызова метода Challenge. На мой взгляд, это самый простой способ «дописать» в заголовки ответа метода Web API нужные данные внутри своей реализации аутентификации. Приложение может конфигурироваться на несколько способов аутентификации через конфиг, и каждый из способов может добавлять к ответу что-то свое. В случае Kerberos это заголовок, а, например, для OAuth мы можем добавить redirect url. Чуть ниже по тексту, когда дойдем до обработчика, я покажу, как это будет выглядеть в коде. Теперь напишем валидатор тикета Kerberos.
Как я ранее упоминал, всю черную магию логики валидации за нас будет делать библиотека Kerberos.NET.
public class KerberosAuthTicketValidator
{
public async Task<ClaimsIdentity> IsValid(string ticket, string keytabPath)
{
if (!string.IsNullOrEmpty(keytabPath) || !string.IsNullOrEmpty(ticket))
{
var kerberosAuth = new KerberosAuthenticator(new KeyTable(File.ReadAllBytes(_kerberosConfiguration.KeytabPath)));
var identity = await kerberosAuth.Authenticate(kerberosCredentials.Ticket);
return identity;
}
return null;
}
}
Как видно по коду, метод валидации тикета KerberosAuthenticator.Authenticate() возвращает ClaimsIdentity, что весьма удобно. И в общем-то это весь код для валидации. Хорошо, когда есть добрые люди, которые делают сложные вещи и делятся ими на github.
Пришло время для самого интересного — хэндлера (обработчика запросов) аутентификации.
В начале я упоминал, что свою реализацию делал на основе уже готовой Cookie Authentication. Класс хэндлера этой аутентификации называется CookieAuthenticationHandler. Просто наследуем свой обработчик от него:
public class MixedAuthenticationHandler : CookieAuthenticationHandler{}
Тут я покажу перегрузку только двух методов, т.к. больше в рамках статьи не требуется. Однако доступных для перегрузки методов ощутимо больше, и можно довольно сильно кастомизировать их под свои нужды.
Перегрузим методы:
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var authResult = await base.HandleAuthenticateAsync(); // Проверяем, может мы уже //аутентифицированы
if (!authResult.Succeeded) // Если нет, то пытаемся
{
string authorizationHeader = Request.Headers["Authorization"];
if (string.IsNullOrEmpty(authorizationHeader))
{
return AuthenticateResult.Fail(”Не получилось”);
}
// не забываем, что в заголовке приходит не чистый тикет - в начале идет “Negotiate”. //Поэтому отрежем лишнее
var ticket = authorizationHeader.Substring(MixedAuthenticationDefaults.AuthorizationHeader.Length);
//теперь у нас есть тикет без лишнего мусора
var kerberosAuthTicketValidator = new KerberosAuthTicketValidator();
var kerberosIdentity = await kerberosAuthTicketValidator.IsValid(new KerberosAuthorizeCredentials(ticket));
if (kerberosIdentity != null)
{
//собираем ClaimsPrincipal
var principal = new ClaimsPrincipal(kerberosIdentity);
//создаем тикет аутентификации
var authTicket= new AuthenticationTicket(principal, MixedAuthenticationDefaults.AuthenticationScheme);
if (ticket != null)
{
//если создался, то вызываем базовый метод, чтобы вся кухня хранения аутентификации в cookie сработала
await base.HandleSignInAsync(principal, ticket.Properties);
//возвращаем успешный результат
return AuthenticateResult.Success(ticket);
}
}
}
return authResult;
}
HandleAuthenticateAsync() — точка входа аутентификации в приложении. Именно он содержит логику, пропускать запрос дальше к методам контроллеров или нет. Теперь HandleChallengeAsync(). Именно он вызывается после того, как выше в статье в контроллере мы обращались к методу Challenge(). Как раз тут есть возможность использовать разную логику для разных способов аутентификаций. Например, добавлять redirect url для oauth.
В нашем случае нужно добавить только заголовок и поставить статус код:
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.StatusCode = 401; //статус код “Unauthorized”
Response.Headers.Append(HeaderNames.WWWAuthenticate, MixedAuthenticationDefaults.AuthorizationHeader);
return Task.CompletedTask;
}
И последнее. Чтобы регистрировать нашу самописную аутентификацию так же удобно, как и встроенную,
public void ConfigureServices(IServiceCollection services)
{
.....
//наша "донорская" схема аутентификации
services.AddAuthentication().AddCookie();
....
}
необходимо сделать метод расширения:
public static class MixedAuthenticationExtensions
{
public static AuthenticationBuilder AddMixed(this AuthenticationBuilder builder)
{
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<CookieAuthenticationOptions>, PostConfigureCookieAuthenticationOptions>());
return builder.AddScheme<CookieAuthenticationOptions, MixedAuthenticationHandler>(MixedAuthenticationDefaults.AuthenticationScheme, String.Empty, null);
}
}
Теперь можно писать так:
public void ConfigureServices(IServiceCollection services)
{
...
//идентично встроенной
services.AddAuthentication(MixedAuthenticationDefaults.AuthenticationScheme).AddMixed();
...
}
В итоге кода нужно совсем немного. При этом мы имеем всю логику работы стандартной cookie аутентификации — запись, валидация, контроль времени жизни и прочее. Можно чуть более заморочиться, выделить абстракцию IAuthenticator, и через DI протаскивать в хэндлер логику в зависимости от настроек.