Понадобилось мне написать некое ASP.NET WebApi приложение, и клиентское приложение на Javascript с использованием этого API. Решено было писать на ASP.NET 5, заодно и изучить новый релиз.

Если бы это было обычное MVC приложение, я бы использовал cookie-based аутентификацию, но кросс-доменные запросы не позволяют передавать куки. Следовательно, необходимо использовать token-based аутентификацию.

Microsoft предлагает свою реализацию — JwtBearerAuthentication. Но охота же самому во всем разобраться. Поэтому я решил написать свою реализацию — BearerAuthentication.

Алгоритм аутентификации пользователей


Пользователь вводит логин и пароль, которые POST-запросом через AJAX отправляются на сервер. Сервер аутентифицирует пользователя и генерирует некий токен, который отправляет пользователю в заголовках ответа. При каждом новом запросе на API, клиентское приложение должно будет в заголовке запроса отправлять принятый токен. Чтобы его не потерять, можно хранить токен в куках (да, опять куки, но теперь он используется только в клиентской части).

Реализация


Текущая версия ASP.NET 5 — RC1 Update1. Для реализации аутентификации нам потребуется пакет Microsoft.AspNet.Authentication.
Ниже приводится список основных классов, которые необходимо реализовать:

BearerAuthenticationExtensions — содержит методы UseBearerAuthentication расширения интерфейса IApplicationBuilder. Здесь просто вызывается метод app.UseMiddleware();
BearerAuthenticationMiddleware — наследует класс AuthenticationMiddleware;
BearerAuthenticationOptions — наследует класс AuthenticationOptions;
BearerAuthenticationHandler — наследует класс AuthenticationHandler и является основным классом для обработки запросов аутентификации.
Вспомогательные классы:
BearerAuthenticationDefaults — содержит строковые константы AuthenticationScheme и HeaderName;
IBearerAuthenticationEvents — интерфейс, определяющий методы, которые вызываются из BearerAuthenticationHandler, для включения возможности обработки запросов вне middleware. Реализацию этого интерфейса можно указать в BearerAuthenticationOptions.

Рассмотрим класс BearerAuthenticationOptions.

public class BearerAuthenticationOptions : AuthenticationOptions, IOptions<BearerAuthenticationOptions>
{
    public BearerAuthenticationOptions()
    {
        AuthenticationScheme = BearerAuthenticationDefaults.AuthenticationScheme;
        HeaderName = BearerAuthenticationDefaults.HeaderName;
        SystemClock = new SystemClock();
        Events = new BearerAuthenticationEvents();
    }

    public string HeaderName { get; set; }

    public ISecureDataFormat<AuthenticationTicket> TicketDataFormat { get; set; }

    public IDataProtectionProvider DataProtectionProvider { get; set; }

    public ISystemClock SystemClock { get; set; }

    public IBearerAuthenticationEvents Events { get; set; }

    public BearerAuthenticationOptions Value => this;
}

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

Класс BearerAuthenticationMiddleware имеет переопределенный метод CreateHandler(), который возвращает новый экземпляр класса BearerAuthenticationHandler.

Теперь рассмотрим, как происходит обработка запросов аутентификации в классе BearerAuthenticationHandler. Этот класс содержит несколько переопределенных методов:

HandleSignInAsync — здесь мы должны создать тикет (AuthenticationTicket), зашифровать его и записать в заголовки ответа. Тикет формируется из ClaimsPrincipal, AuthenticationProperties и AuthenticationScheme;
HandleSignOutAsync — здесь мы просто будем в заголовке ответа писать пустоту, чтобы клиентское приложение приняло пустой токен;
HandleAuthenticateAsync — обработчик запросов — здесь мы должны расшифровать токен из заголовка в тикет, и проверить его срок истечения;
HandleUnauthorizedAsync — на неавторизованные запросы будем отвечать с кодом 401;
HandleForbiddenAsync — на запросы, к которым пользователю закрыт доступ, отвечаем с кодом 403;
FinishResponseAsync — вызывается после каждого обработчика запроса.

Исходный код класса:

public class BearerAuthenticationHandler : AuthenticationHandler<BearerAuthenticationOptions>
{
    private bool _shouldRenew;

    private AuthenticationTicket GetTicket()
    {
        if (!Context.Request.Headers.ContainsKey(Options.HeaderName))
            return null;
        var bearer = Context.Request.Headers[Options.HeaderName];
        if (string.IsNullOrEmpty(bearer))
            return null;

        var ticket = Options.TicketDataFormat.Unprotect(bearer);
        if (ticket == null)
            return null;

        var currentUtc = Options.SystemClock.UtcNow;
        var expiresUtc = ticket.Properties.ExpiresUtc;
        if (expiresUtc.HasValue && expiresUtc.Value < currentUtc)
            return null;

        return ticket;
    }

    private void ApplyBearer(AuthenticationTicket ticket)
    {
        if (ticket != null)
        {
            var protectedData = Options.TicketDataFormat.Protect(ticket);
            Response.Headers["Access-Control-Expose-Headers"] = Options.HeaderName;
            Response.Headers[Options.HeaderName] = protectedData;
        }
        else
        {
            Response.Headers["Access-Control-Expose-Headers"] = Options.HeaderName;
            Response.Headers[Options.HeaderName] = StringValues.Empty;
        }
    }

    protected override async Task HandleSignInAsync(SignInContext signIn)
    {
        var signingInContext = new BearerSigningInContext(Context, Options, signIn.Principal, new AuthenticationProperties(signIn.Properties));
        await Options.Events.SigningIn(signingInContext);

        var ticket = new AuthenticationTicket(signingInContext.Principal, signingInContext.Properties, Options.AuthenticationScheme);
        ApplyBearer(ticket);

        var signedInContext = new BearerSignedInContext(Context, Options, signingInContext.Principal, signingInContext.Properties);
        await Options.Events.SignedIn(signedInContext);
    }

    protected override async Task HandleSignOutAsync(SignOutContext context)
    {
        var signingOutContext = new BearerSigningOutContext(Context, Options);
        await Options.Events.SigningOut(signingOutContext);
        ApplyBearer(null);
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var ticket = GetTicket();
        if (ticket == null)
            return AuthenticateResult.Failed("No ticket.");

        var context = new BearerValidatePrincipalContext(Context, Options, ticket.Principal, ticket.Properties);
        await Options.Events.ValidatePrincipal(context);
        if (context.Principal == null)
            return AuthenticateResult.Failed("No principal.");

        if (context.ShouldRenew)
            _shouldRenew = true;

        return AuthenticateResult.Success(new AuthenticationTicket(context.Principal, context.Properties, Options.AuthenticationScheme));
    }

    protected override async Task<bool> HandleUnauthorizedAsync(ChallengeContext context)
    {
        Response.StatusCode = 401;

        var unauthorizedContext = new BearerUnauthorizedContext(Context, Options);
        await Options.Events.Unauthorized(unauthorizedContext);
        return true;
    }

    protected override async Task<bool> HandleForbiddenAsync(ChallengeContext context)
    {
        Response.StatusCode = 403;

        var forbiddenContext = new BearerForbiddenContext(Context, Options);
        await Options.Events.Forbidden(forbiddenContext);
        return true;
    }

    protected override async Task FinishResponseAsync()
    {
        if (!_shouldRenew || SignInAccepted || SignOutAccepted)
            return;

        var result = await HandleAuthenticateOnceAsync();
        var ticket = result?.Ticket;
        if (ticket == null)
            return;

        ApplyBearer(ticket);
    }
}

Аутентификацию пользователя можно вызвать, например, из контроллера:

await HttpContext.Authentication.SignInAsync(BearerAuthenticationDefaults.AuthenticationScheme, principal);

где principal — это экземпляр класса ClaimsPrincipal, который будет передан в метод HandleSignInAsync.

Данный пример лишь демонстрирует процесс обработки аутентификации. Конечно, можно еще расширить этот обработчик, добавив, например, возможность сохранения токена в сессии в ITicketStore.

Исходники проекта можно взять на GitHub.

Комментарии (3)


  1. musuk
    28.12.2015 14:16
    +1

    Кстати, был бы интересен полновесный пример, готовый к использованию в реальном мире.
    — кастомные данные в токене (Claims)
    — продление действия токена
    — авторизация через внешние сервисы.


    1. adeptuss
      28.12.2015 14:54

      Да, пример не полный и был написан в ходе разработки собственного проекта. Говорить об использовании «на бою» еще рано, т.к. релиза ASP.NET 5 не было, и еще многое может поменяться.
      В исходниках на GitHub вы можете увидеть, что в Claims можно передавать любые типы и строковые значения.
      Продление токена возможно осуществить, переопределив метод ValidatePrincipal в options.Events. Но нужно не забыть установить значение context.ShouldRenew в true.
      Задачу авторизации через внешние сервисы я не ставил. Реализаций от Microsoft, мне кажется, достаточно.
      Возможно, проект на GitHub-е я еще буду наращивать :)


  1. Smerig
    28.12.2015 14:28

    Круто, конечно, но я как-то для кроссдоменной аутентификации использовал ASP.AUTH куку, которую шифровал и передавал обратно клиенту. Клиент уже заходил с зашифрованной кукой на другой домен, расшифровывал ее, смотрел на время действия куки, если она была старее, скажем, двух секунд, то отпинывал, иначе аутентифицировал пользователя.

    Наверное через жопу, но смысл тот же самый и без всяких оберток с bearer etc. Но тогда и ASP.NET 5 не было.