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