Аутентификация в 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 аутентификации состоит из нескольких шагов:


  1. Обращение неаутентифицированного клиента в web-приложение
  2. Предварительно настроенное на поддержку Kerberos приложение получает запрос от неизвестного клиента и желает опознать его. Для этого оно в ответе на определенный метод *Web API* добавляет заголовок WWW-Authenticate со значением Negotiate
  3. Браузер видит заголовок WWW-Authenticate со значением Negotiate и понимает, что приложение хочет опознать пользователя по доменной сессии
  4. Если все настроено корректно, то на приложение уходит запрос с проставленным заголовком Authorization и значением вида Negotiate {Kerberos тикет}
  5. Приложение валидирует тикет и получает информацию о пользователе из домена
  6. 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 протаскивать в хэндлер логику в зависимости от настроек.