Web API авторизация Bearer с поддержкой cookies
Сайтостроение | создано: 27.10.2016 | опубликовано: 27.10.2016 | обновлено: 13.01.2024 | просмотров: 23795
В статье описывается как для 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.