Web API авторизация Bearer с поддержкой cookies

Сайтостроение | создано: 27.10.2016 | опубликовано: 27.10.2016 | обновлено: 22.10.2017 | просмотров: 4485

В статье описывается как для Web API использовать OAuth 2.0 аутентификацию и авторизацию на основе access_token (Bearer), и как этот токен хранить в cookie чтобы не приходилось при каждом новом открытии сайта вводить данные для получения этого токена.

Описание

Если вы захотите использовать OAuth 2.0 аутентификацию (и авторизацию) на основе access_token (например, Bearer), то вам придется в в заголовке каждого запроса передавать этот самый токен. Тут, как говорится, ничего нового, всё уже придумали за нас. Но если это так, то встает резонный вопрос: где его брать, чтобы его передавать? Об этом и о многом другом пойдет речь в этой статье.

Задача

Когда у меня созрела идея написания этой статьи, передо мной стояла задача запустить одностраничный сайт (Single Page Application) без использования ASP.NET MVC, но с возможностью использования Web API. Задача решена. Давайте ее разложим по пунктам. Итак, для решения задачи требуется решить следующие задачи:

  • получить токен (acces_token);
  • сохранить полученный токен в cookie;
  • при последующих посещениях сайта читать токен из cookie, чтобы аутентифицировать пользователя автоматически без ввода пароля и лонина.

Мелкие нюансы типа "поднять токен сервис" или "запросить у пользователя логин, если токен не обнаружен в cookie" опущены, хотя и обязательны.

Используемые инструменты и технологии

Я буду использовать Visual Studio 2015. При создания проекта я не использовал ASP.NET MVC 5, а создал пустой solution.

После этого я установил нужные nuget-пакеты, чтобы у меня получился проект с одной HTML-страницей (Single Page Application) и подключенным Web API. Для полноты картины приведу весь список установленных пакетов.

Обратите внимания, что у меня нет пакетов ASP.NET MVC в списке установленных.

Если вы скопировали файл packages.config из другого источника или создали его вручную без установки каждого из пакетов, то в Package Managment Console достаточно ввести следующую команду, чтоб все пакеты установились автоматически.

>Update-Package -Reinstall

Далее подключим OWIN для сайта. Для этого создаем файл Startup.cs:

Теперь приведу его полное содержимое и после чего, как уже принято приведу пояснения к коду.

using System;
using Autofac;
using Autofac.Integration.WebApi;
using Calabonga.Facts;
using Microsoft.AspNet.Identity;
using Microsoft.Owin;
using Microsoft.Owin.Security.OAuth;
using Owin;

[assembly: OwinStartup(typeof(Startup))]
namespace Calabonga.Facts {

    /// <summary>
    /// Start for Owin
    /// </summary>
    public class Startup {

        /// <summary>
        /// Server OAuthorization Options
        /// </summary>
        public static OAuthAuthorizationServerOptions OAuthAuthorizationServer { get; set; }

        public void Configuration(IAppBuilder app)
        {
            var config = ConfigurationBuilder.Create();
            var container = DependencyContainer.Initialize(app);
            config.DependencyResolver = new AutofacWebApiDependencyResolver(container);
            var provider = container.Resolve<ApplicationOAuthProvider>();
            OAuthAuthorizationServer = new OAuthAuthorizationServerOptions {
                AllowInsecureHttp = true,
                TokenEndpointPath = new PathString("/custom-token"),
                AccessTokenExpireTimeSpan = TimeSpan.FromDays(1),
                Provider = provider
            };
            app.UseOAuthAuthorizationServer(OAuthAuthorizationServer);
            app.UseBearerOnCookieAuthentication();
            app.UseAutofacMiddleware(container);
            app.UseAutofacWebApi(config);
            app.UseWebApi(config);
            app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions());
            app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);
            app.UseSpaWebApi();
        }
    }
}

Так как этот файл, и в частности метод Configuration использует классы, которые буду показаны позже, проект не может быть собран и запущен. Поэтому не пытайтесь это сделать. Теперь, прокомментирую код по порядку следования строк, создавая недостающие классы и настройки.

Startup: Строки 6-7

Используется OWIN как основная спецификация взаимодействия между компонентами. ASP.NET MVC при этом не используется.

Startup: Строка 10

Стандартный для OWIN атрибут указывающий на то что при старте приложения класс c настройками для запуска является Startup.cs.  

Startup: Строка 25

Создаем конфигурацию Web API и настраиваем основные параметры.

using System.Web.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;

namespace Calabonga.Facts
{
    public static class ConfigurationBuilder
    {
        public static HttpConfiguration Create()
        {
            var config = new HttpConfiguration();

            config.SuppressDefaultHostAuthentication();
            config.Filters.Add(new AuthorizationBearerFilter());
            
            // Attribute routing.
            config.MapHttpAttributeRoutes();

            // Convention-based routing.
            config.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional }
            );

            // formatter settings
            config.Formatters.JsonFormatter.SerializerSettings.Formatting = Formatting.Indented;
            config.Formatters.JsonFormatter.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
            config.Formatters.JsonFormatter.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;


            return config;
        }
    }
}

Важные строки выделены цветом. Здесь отключаем стандартную аутентификацию на сайте и регистрируем свой собственный AuthorizationBearerFilter, который выглядит следующим образом.

using System;
using System.Collections.Generic;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Http.Filters;

namespace WebApplication1
{
    /// <summary>
    /// Custom authorization filter
    /// </summary>
    public class AuthorizationBearerFilter : Attribute, IAuthenticationFilter {

        public bool AllowMultiple { get { return false; } }

        public Task AuthenticateAsync(HttpAuthenticationContext context, CancellationToken cancellationToken) {

            var request = context.Request;
            var authorization = request.Headers.Authorization;
            if (authorization == null) {
                return null;
            }
            if (authorization.Scheme != "Bearer") return null;

            cancellationToken.ThrowIfCancellationRequested();

            var ticket = Startup.OAuthAuthorizationServer.AccessTokenFormat.Unprotect(authorization.Parameter);
            if (ticket == null) return Task.CompletedTask;

            // do validation with ticket
            var nameClaim = new Claim(ClaimTypes.Name, "UserName");
            var claims = new List<Claim> { nameClaim };
            var identity = new ClaimsIdentity(claims, "Bearer");
            var principal = new ClaimsPrincipal(identity);
            context.Principal = principal;
            return Task.CompletedTask;
        }

        public Task ChallengeAsync(HttpAuthenticationChallengeContext context, CancellationToken cancellationToken) {
            var challenge = new AuthenticationHeaderValue("Bearer");
            context.Result = new AddChallengeOnUnauthorizedResult(challenge, context.Result);
            return Task.FromResult(0);
        }
    }
}

Комментарии по этому коду: в строке 11 проверяем наличие Authorization в Header; в строке 19 распаковываем (расшифровка) ticket и проделываем нужные нам манипуляции для проверки пользователя; в 27 строке устанавливаем IPrincipal для текущего запроса. Именно в 27 строке вы можете указать нужные параметры для пользователя: роли, права, настройки и т.д., которые, кстати, можно получить из БД или из другого сервиса.

Возвращаемся к Startup, следующая строка, требующая внимания, это строка номер 26, где происходит инициализация DependencyContainer. Я также приведу его полностью, закомментировав неважные строки:

using System.Reflection;
using Autofac;
using Autofac.Integration.WebApi;
using log4net;
using Owin;

namespace WebApplication1
{
    /// <summary>
    /// Dependancy Container
    /// </summary>
    public static class DependencyContainer {
        /// <summary>
        /// Initialize container
        /// </summary>
        /// <param name="app"></param>
        internal static IContainer Initialize(IAppBuilder app) {

            var builder = new ContainerBuilder();
            builder.RegisterApiControllers(Assembly.GetExecutingAssembly());

            // -----------------------------------------------------------------
            // my services and DbContext registered here
            // -----------------------------------------------------------------
            // builder.RegisterType<FactService>().As<IFactService>();
            // builder.RegisterType<TagService>().As<ITagService>();
            // builder.RegisterType<ApplicationDbContext>().As<IContext>();
            // builder.RegisterType<AccountMananger>().As<IAccountMananger>();
            // builder.RegisterType<Config>().As<IAppConfig>();
            // builder.RegisterType<CacheService>().As<ICacheService>();
            // builder.RegisterType<DefaultConfigSerializer>().As<IConfigSerializer>();

            builder.RegisterType<ApplicationOAuthProvider>().AsSelf().SingleInstance();

            builder.RegisterInstance(LogManager.GetLogger(typeof(Startup))).As<ILog>();

            return builder.Build();
        }
    }
}

Вы можете и вовсе не использовать DI-контейнер, или использовать другой на своё усмотрение. На самом деле, нам интересна выделенная 33 строка. В этой строке в DI-контейнере регистрируется, пожалуй самый главный класс, который отвечает за авторизацию и аутентификацию. Приведу его полностью, и как водится с комментариями после:

using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using Calabonga.Facts.Extensions;
using Calabonga.Facts.ViewModels;
using Microsoft.AspNet.Identity;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.OAuth;

namespace Calabonga.Facts {

    public class ApplicationOAuthProvider : OAuthAuthorizationServerProvider {
        private readonly IAccountMananger _mobileAccountMananger;
        private readonly string _publicClientId;

        public ApplicationOAuthProvider(IAccountMananger mobileAccountMananger) {
            _mobileAccountMananger = mobileAccountMananger;
            _publicClientId = DefaultAuthenticationTypes.ExternalBearer;
        }

        public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context) {
            
            var client = new LoginViewModel {
                Password = context.Password,
                UserName = context.UserName
            };

            var validateOperation = await _mobileAccountMananger.AuthorizeUserAsync(client);
            if (!validateOperation.Ok) {
                context.SetError("invalid_grant", validateOperation.GetMetadataMessages());
            }
            else {
                var oAuthIdentity = new ClaimsIdentity(validateOperation.Result.Claims, _publicClientId);
                var cookiesIdentity = new ClaimsIdentity(validateOperation.Result.Claims, _publicClientId);
                var properties = CreateProperties(context.UserName, GetType().Namespace);
                var ticket = new AuthenticationTicket(oAuthIdentity, properties);
                context.OwinContext.Response.Headers.Add("Access-Control-Allow-Origin", new[] { "*" });
                context.Request.Context.Authentication.SignIn(cookiesIdentity);
                context.Response.Cookies.Append(TokenName, context.Options.AccessTokenFormat.Protect(ticket));
                context.Validated(ticket);
            }
        }

        internal static string TokenName { get; } = "Token";

        public override Task TokenEndpoint(OAuthTokenEndpointContext context) {
            foreach (var property in context.Properties.Dictionary) {
                context.AdditionalResponseParameters.Add(property.Key, property.Value);
            }
            return Task.FromResult<object>(null);
        }

        public override Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context) {
            // Resource owner password credentials does not provide a client ID.
            if (context.ClientId == null) {
                context.Validated();
            }

            return Task.FromResult<object>(null);
        }

        public override Task ValidateClientRedirectUri(OAuthValidateClientRedirectUriContext context) {
            if (context.ClientId == _publicClientId) {
                var expectedRootUri = new Uri(context.Request.Uri, "/");
                if (expectedRootUri.AbsoluteUri == context.RedirectUri) {
                    context.Validated();
                }
            }
            return Task.FromResult<object>(null);
        }

        /// <summary>
        /// Create Authentication properties
        /// </summary>
        /// <param name="userName"></param>
        /// <param name="appName"></param>
        /// <returns></returns>
        private static AuthenticationProperties CreateProperties(string userName, string appName) {
            IDictionary<string, string> data = new Dictionary<string, string>
            {
                { "UserName", userName },
                { "ApplicationName", appName }
            };
            return new AuthenticationProperties(data);
        }
    }
}

ApplicationOAuthProvider: Строка 13

Требуется создать свою реализацию OAuthAuthorizationServerProvider, поэтому наш класс унаследован от этого класса.

ApplicationOAuthProvider: Строка 19

Указываем тип аутентификации.

ApplicationOAuthProvider: Строка 24

Я использую врутренний ViewModel для проброса данных в сервис аутентификации в строке 29.

ApplicationOAuthProvider: Строка 31

Возращает отрицательный ответ о том, что аутентификация не увенчалась успехом с указанием причин.

ApplicationOAuthProvider: Строка 41

​Возращает зашифрованный AutenticationTicket, как результат успешной аутентификации.

Теперь снова вернемся к Startup классу.

Startup: Строка 36 и 42

В 36 как и 42 строке используется расширение AppBuilder.

using Owin;

namespace WebApplication1
{
    /// <summary>
    /// Static extensions for AppFunc
    /// </summary>
    public static class AppFuncExtensions {
        /// <summary>
        /// Setup to use WebApiAllication as default framework for Application
        /// </summary>
        /// <param name="app"></param>
        public static void UseSpaWebApi(this IAppBuilder app) {
            app.Use<SinglePageWithWebApi>();
        }

        /// <summary>
        /// Use bearer authentication on cookie
        /// </summary>
        /// <param name="app"></param>
        public static void UseBearerOnCookieAuthentication(this IAppBuilder app) {
            app.Use<BearerOnCookieAuthentication>();
        }
    }
}

В строке 14 по сути происходит запуск сайта. Так как я не использую ASP.NET MVC, то всё-таки хоть что-то должно как-то выводиться в браузер. Именно этот класс SinglePageWithWebApi и читает файл из папки Views и рендерит его в поток вызова без изменений HTML.

/// <summary>
/// Web API application for Single Page Application
/// </summary>
public class SinglePageWithWebApi : OwinMiddleware {

    public SinglePageWithWebApi(OwinMiddleware next) : base(next) { }

    public override async Task Invoke(IOwinContext context) {
        var filePath = HttpContext.Current.Server.MapPath(string.Concat("~/", "views/index.html"));
        var content = File.ReadAllText(filePath);
        await context.Response.WriteAsync(content);
    }
}

В строке 22 подключается класс BearerOnCookieAuthentication (middleware), ради которого и затевалась эта статья. Именно этот представленный ниже класс проделывает основную работу по обеспечению чтению Cookie и записи его наличия в свойство Authorization в коллекцию Header:

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Owin;

namespace WebApplication1
{
    /// <summary>
    /// Middleware for OWIN enables using bearer authentication over cookies
    /// </summary>
    public class BearerOnCookieAuthentication : OwinMiddleware {

        public BearerOnCookieAuthentication(OwinMiddleware next) : base(next) { }

        public override async Task Invoke(IOwinContext context) {
            var cookies = context.Request.Cookies;
            var cookie = cookies.FirstOrDefault(c => c.Key == ApplicationOAuthProvider.TokenName);
            if (!context.Request.Headers.ContainsKey("Authorization")) {
                if (!cookie.Equals(default(KeyValuePair<string, string>))) {
                    var ticket = cookie.Value;
                    context.Request.Headers.Add("Authorization", new[] { $"Bearer {ticket}" });
                }
            }
            await Next.Invoke(context);
        }
    }
}

Теперь весь код собран воедино, следовательно можно откомпилировать проект. После успешного построения я запустил проект, и оказалось, что я случайно закомментировал лишную строку с регистрацией IAccountManager в DependencyContainer. Чтобы запуск состоялся, вам потребуется разкомментировать строчку с IAccountManager, а также вам потребуется файл index.html в папке Views.

Для удобства, тоже приведу полное содержание файла index.html:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Только факты</title>
    <meta name="description" content="Calabonga.Owin.Application" />
    <meta name="keywords" content="Calabonga Owin Application" />
    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
      <script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
      <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
    <![endif]-->
    <link rel="Shortcut Icon" href="/favicon.ico" />
    <link href="../Content/bootstrap.min.css" rel="stylesheet" />
    <link href="../Content/font-awesome.min.css" rel="stylesheet" />
    <link href="../Content/toastr.min.css" rel="stylesheet" />
    <link href="../Content/site.css" rel="stylesheet" />
</head>
<body>
    <div class="container">
        <nav class="navbar navbar-default navbar-fixed-top">
            <div class="container-fluid">
                <div class="navbar-header">
                    <button type="button" class="navbar-toggle collapsed" 
                            data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" 
                            aria-expanded="false">
                        <span class="sr-only">Toggle navigation</span>
                        <span class="icon-bar"></span>
                        <span class="icon-bar"></span>
                        <span class="icon-bar"></span>
                    </button>
                    <a class="navbar-brand" href="/">
                        <img alt="Brand" src="/Content/logo.png" class="img-responsive">
                    </a>
                </div>

                <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
                    <ul class="nav navbar-nav">
                        <li><a href="http://www.calabonga.net">Блог разработчика</a></li>
                        <li><a href="http://www.calabonga.net/blog/post/184">Ссылка на статью</a></li>
                        <li><a href="http://www.calabonga.net/blog/post/141">Что такое SPA</a></li>
                    </ul>
                    <div class="navbar-form navbar-right">
                        <div class="form-group">
                            <div class="input-group">
                                <span class="input-group-addon"><i class="fa fa-filter"></i></span>
                                <input type="text" class="form-control" placeholder="Search">
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </nav>
        <div class="row">
            <h1>Welcome</h1>
            <p>
                Авторизация настроена, хотя это и не видно. Единственное, что вам придется
                сделать самостоятельно, так это реализовать JavaScript функционал, который
                будет обращаться к ApiController.
            </p>
            <p>
                ApiController теперь может быть помечен атрибутом Autorize. Авторизация и
                аутентификация будет осуществляться через access_token.
            </p>
        </div>
    </div>
    <script src="../Scripts/jquery-3.1.1.min.js"></script>
</body>
</html>

Заключение

Авторизация настроена, хотя это и не видно. Единственное, что вам придется сделать самостоятельно, так это реализовать JavaScript функционал, который будет обращаться к ApiController, который вы тоже должны будете создать самостоятельно. ApiController теперь может быть помечен атрибутом Autorize. Авторизация и аутентификация будет осуществляться через access_token.

Ссылки

В заключении ссылка на вновь созданный проект, который выложен на GitHub.