[Из песочницы] ASP.NET 5. Аутентификация по токену

Понадобилось мне написать некое 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
{
    public BearerAuthenticationOptions()
    {
        AuthenticationScheme = BearerAuthenticationDefaults.AuthenticationScheme;
        HeaderName = BearerAuthenticationDefaults.HeaderName;
        SystemClock = new SystemClock();
        Events = new BearerAuthenticationEvents();
    }

    public string HeaderName { get; set; }

    public ISecureDataFormat 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
{
    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 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 HandleUnauthorizedAsync(ChallengeContext context)
    {
        Response.StatusCode = 401;

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

    protected override async Task 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.

© Habrahabr.ru