Logo designed by Pablo Iglesias.
В статье описаны паттерны и приемы авторизации в ASP.NET Core MVC. Подчеркну, что рассматривается только авторизация (проверка прав пользователя) а не аутентификация, поэтому в статье не будет использования ASP.NET Identity, протоколов аутентификации и т.п. Будет много примеров серверного кода, небольшой экскурс вглубь исходников Core MVC, и тестовый проект (ссылка в конце статьи). Приглашаю интересующихся под кат.
Содержание:
- Claims
- Подготовительные работы
- Атрибут Authorize и политики доступа
- Настройки политик доступа
- Resource-based авторизация
- Авторизация в Razor-разметке
- Permission-based авторизация. Свой фильтр авторизации
Claims
Принципы авторизации и аутентификации в ASP.NET Core MVC не изменились по сравнению с предыдущей версией фреймворка, отличаясь лишь в деталях. Одним из относительно новых понятий является claim-based авторизация, с нее мы и начнем наше путешествие. Что же такое claim? Это пара строк "ключ-значение", в качестве ключа может выступать "FirstName", "EmailAddress" и т.п. Таким образом, claim можно трактовать как свойство пользователя, как строку с данными, или даже как некоторое утверждение вида "у пользователя есть что-то". Знакомая многим разработчикам одномерная role-based модель органично содержится в многомерной claim-based модели: роль (утверждение вида "у пользователя есть роль X") представляет собой один из claim и содержится в списке преопределенных System.Security.Claims.ClaimTypes. Не возбраняется создавать и свои claim.
Следующее важное понятие — identity. Это единое утверждение, содержащее набор claim. Так, identity можно трактовать как цельный документ (паспорт, водительские права и др.), в этом случае claim — строка в паспорте (дата рождения, фамилия...). В Core MVC используется класс System.Security.Claims.ClaimsIdentity.
Еще на уровень выше находится понятие principal, обозначающее самого пользователя. Как в реальной жизни у человека может быть на руках несколько документов одновременно, так и в Core MVC — principal может содержать несколько ассоциированных с пользователем identity. Всем известное свойство HttpContext.User в Core MVC имеет тип System.Security.Claims.ClaimsPrincipal. Естественно, через principal можно получить все claim каждого identity. Набор из более чем одного identity может использоваться для разграничения доступа к различным разделам сайта/сервиса.
На диаграмме указаны лишь некоторые свойства и методы классов из пространства имен System.Security.Claims.
Зачем это все нужно? При claim-based авторизации, мы явно указываем, что пользователю необходимо иметь нужный claim (свойство пользователя) для доступа к ресурсу. В простейшем случае, проверяется сам факт наличия определенного claim, хотя возможны и куда более сложные комбинации (задаваемые при помощи policy, requirements, permissions — мы подробно рассмотрим эти понятия ниже). Пример из реальной жизни: для управления легковым авто, у человека должны быть водительские права (identity) с открытой категорией B (claim).
Подготовительные работы
Здесь и далее на протяжении статьи, мы будем настраивать доступ для различных страниц веб-сайта. Для запуска представленного кода, достаточно создать в Visual Studio 2015 новое приложение типа "ASP.NET Core Web Application", задать шаблон Web Application и тип аутентификации "No Authentication".
При использовании аутентификации "Individual User Accounts" был бы сгенерирован код для хранения и загрузки пользователей в БД посредством ASP.NET Identity, EF Core и localdb. Что является совершенно избыточным в рамках данной статьи, даже несмотря на наличие легковесного EntityFrameworkCore.InMemory решения для тестирования. Более того, нам в принципе не потребуется библиотека аутентификации ASP.NET Identity. Получение principal для авторизации можно самостоятельно эмулировать in-memory, а сериализация principal в cookie возможна стандартными средствами Core MVC. Это всё, что нужно для нашего тестирования.
Для эмуляции хранилища пользователей достаточно открыть Startup.cs и зарегистрировать сервисы-заглушки во встроенном DI-контейнере:
public void ConfigureServices(IServiceCollection services)
{
//включаем Identity
services.AddIdentity<IdentityUser, IdentityRole>();
//регистрируем хранилище
services.AddTransient<IUserStore<IdentityUser>, FakeUserStore>();
services.AddTransient<IRoleStore<IdentityRole>, FakeRoleStore>();
}
Кстати, мы всего лишь проделали ту же работу, что проделал бы вызов AddEntityFrameworkStores<TContext>:
services.AddIdentity<IdentityUser, IdentityRole>()
.AddEntityFrameworkStores<IdentityDbContext>();
Начнем с авторизации пользователя на сайте: на GET /Home/Login
нарисуем форму-заглушку, добавим кнопку для отправки пустой формы на сервер. На POST /Home/Login
вручную создадим principal, identity и claim (в реальном приложении эти данные были бы получены из БД). Вызов HttpContext.Authentication.SignInAsync
сериализует principal и поместит его в зашифрованный cookie, который в свою очередь будет прикреплен к ответу веб-сервера и сохранен на стороне клиента:
[HttpGet]
[AllowAnonymous]
public IActionResult Login(string returnUrl = null)
{
ViewData["ReturnUrl"] = returnUrl;
return View();
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginViewModel vm, string returnUrl = null)
{
//TODO: проверка пароля, загрузка пользователя из БД, и т.д. и т.п.
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, "Fake User"),
new Claim("age", "25", ClaimValueTypes.Integer)
};
var identity = new ClaimsIdentity("MyCookieMiddlewareInstance");
identity.AddClaims(claims);
var principal = new ClaimsPrincipal(identity);
await HttpContext.Authentication.SignInAsync("MyCookieMiddlewareInstance",
principal,
new AuthenticationProperties
{
ExpiresUtc = DateTime.UtcNow.AddMinutes(20)
});
_logger.LogInformation(4, "User logged in.");
return RedirectToLocal(returnUrl);
}
Включим cookie-аутентификацию в методе Startup.Configure(app):
app.UseCookieAuthentication(new CookieAuthenticationOptions()
{
AuthenticationScheme = "MyCookieMiddlewareInstance",
CookieName = "MyCookieMiddlewareInstance",
LoginPath = new PathString("/Home/Login/"),
AccessDeniedPath = new PathString("/Home/AccessDenied/"),
AutomaticAuthenticate = true,
AutomaticChallenge = true
});
Этот код с небольшими модификациями будет основой для всех последующих примеров.
Атрибут Authorize и политики доступа
Атрибут [Authorize]
никуда не делся из MVC. По-прежнему, при маркировке controller/action этим атрибутом — доступ внутрь получит только авторизованный пользователь. Вещи становятся интереснее, если дополнительно указать название политики (policy) — некоторого требования к claim пользователя:
[Authorize(Policy = "age-policy")]
public IActionResult About() { return View(); }
Политики создаются в уже известном нам методе Startup.ConfigureServices
:
services.AddAuthorization(options =>
{
options.AddPolicy("age-policy", x => { x.RequireClaim("age"); });
});
Такая политика устанавливает, что попасть на страницу About сможет только авторизованный пользователь с claim-ом "age", при этом значение claim не учитывается. В следующем разделе, мы перейдем к примерам посложнее (наконец-то!), а сейчас разберемся, как это работает внутри?
[Authorize]
— атрибут маркерный, сам по себе логики не содержащий. Нужен он лишь для того, чтобы указать MVC, к каким controller/action следует подключить AuthorizeFilter — один из встроенных фильтров Core MVC. Концепция фильтров та же, что и в предыдущих версиях фреймворка: фильтры выполняются последовательно, и позволяют выполнить код до и после обращения к controller/action. Важное отличие от middleware: фильтры имеют доступ к специфичному для MVC контексту (и выполняются, естественно, после всех middleware). Впрочем, грань между filter и middleware весьма расплывчата, так как вызов middleware возможно встроить в цепочку фильтров при помощи атрибута [MiddlewareFilter].
Вернемся к авторизации и AuthorizeFilter. Самое интересное происходит в его методе OnAuthorizationAsync:
- Из списка политик выбирается нужная на основе указанного в атрибуте [Authorize] значения (либо берется AuthorizationPolicy — политика по-умолчанию, содержащая всего одно требование с говорящим названием — DenyAnonymousAuthorizationRequirement.
- Выполняется проверка, соответствует ли набор из identity и claim-ов пользователя (например, полученных ранее из cookies запроса) требованиям политики.
Надеюсь, приведенные ссылки на исходный код дали вам представление об внутреннем устройстве фильтров в Core MVC.
Настройки политик доступа
Создание политик доступа через рассмотренный выше fluent-интерфейс не дает той гибкости, которая требуется в реальных приложениях. Конечно, можно явно указать допустимые значения claim через вызов RequireClaim("x", params values)
, можно скомбинировать через логическое И несколько условий, вызвав RequireClaim("x").RequireClaim("y")
. Наконец, можно навесить на controller и action разные политики, что, впрочем, приведет к той же комбинации условий через логическое И. Очевидно, что необходим более гибкий механизм создания политик, и он у нас есть: requirements и handlers.
services.AddAuthorization(options =>
{
options.AddPolicy("age-policy",
policy => policy.Requirements.Add(new AgeRequirement(42), new FooRequirement()));
});
Requirement — не более чем DTO для передачи параметров в соответствующий handler, который в свою очередь имеет доступ к HttpContext.User и волен налагать любые проверки на principal и содержащиеся в нем identity/claim. Более того, handler может получать внешние зависимости через встроенный в Core MVC DI-контейнер:
public class MinAgeRequirement : IAuthorizationRequirement
{
public MinAgeRequirement(int age)
{
Age = age;
}
public int Age { get; private set; }
}
public class MinAgeHandler : AuthorizationHandler<MinAgeRequirement>
{
public MinAgeHandler(IFooService fooService)
{
// fooService будет передан через DI
}
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MinAgeRequirement requirement)
{
bool hasClaim = context.User.HasClaim(c => c.Type == "age");
bool hasIdentity = context.User.Identities.Any(i => i.AuthenticationType == "MultiPass");
string claimValue = context.User.FindFirst(c => c.Type == "age").Value;
if (int.Parse(claimValue) >= requirement.Age)
{
context.Succeed(requirement);
}
else
{
context.Fail();
}
return Task.CompletedTask;
}
}
Регистрируем сам handler в Startup.ConfigureServices(), и он готов к использованию:
services.AddSingleton<IAuthorizationHandler, MinAgeHandler>();
Handler-ы возможно сочетать как через AND, так и через OR. Так, при регистрации нескольких наследников AuthorizationHandler<FooRequirement>
, все они будут вызваны. При этом вызов context.Succeed()
не является обязательным, а вызов context.Fail()
приводит к общему отказу в авторизации вне зависимости от результата других handler. Итого, мы можем комбинировать между собой рассмотренные механизмы доступа следующим образом:
- Policy: AND
- Requirement: AND
- Handler: AND / OR.
Resource-based авторизация
Как уже говорилось ранее, policy-based авторизация выполняется Core MVC в filter pipeline, т.е. ДО вызова защищаемого action. Успех авторизации при этом зависит только от пользователя — либо он обладает нужными claim, либо нет. А что, если необходимо учесть также защищаемый ресурс и его свойства, получить какие данные из внешних источников? Пример из жизни: защищаем action вида GET /Orders/{id}
, считывающий по id строку с заказом из БД. Пусть наличие у пользователя прав на конкретный заказ мы сможем определить только после получения этого заказа из БД. Это автоматически делает непригодными рассмотренные ранее аспектно-ориентированные сценарии на основе фильтров MVC, выполняемых перед тем, как пользовательский код получает управление. К счастью, в Core MVC есть способы провести авторизацию вручную.
Для этого, в контроллере нам потребуется реализация IAuthorizationService
. Получим ее, как обычно, через внедрение зависимости в конструктор:
public class ResourceController : Controller
{
IAuthorizationService _authorizationService;
public ResourceController(IAuthorizationService authorizationService)
{
_authorizationService = authorizationService;
}
}
Затем создадим новую политику и handler:
options.AddPolicy("resource-allow-policy",
x => { x.AddRequirements(new ResourceBasedRequirement()); });
public class ResourceHandler : AuthorizationHandler<ResourceBasedRequirement, Order>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ResourceBasedRequirement requirement,
Order order)
{
// TODO: проверка, имеет ли пользователь права на действия с заказом
if (true) context.Succeed(requirement);
return Task.CompletedTask;
}
}
Наконец, проверяем пользователя + ресурс на соответствие нужной политике внутри action (заметьте, атрибут [Authorize]
больше не нужен):
public async Task<IActionResult> Allow(int id)
{
Order order = new Order(); //получим ресурс из БД
if (await _authorizationService.AuthorizeAsync(User, order, "my-resource-policy"))
{
return View();
}
else
{
//вернем 401 или 403 в зависимости от состояния пользователя
return new ChallengeResult();
}
}
У метода IAuthorizationService.AuthorizeAsync
есть перегрузка, принимающая список из requirement — вместо названия политики:
Task<bool> AuthorizeAsync(
ClaimsPrincipal user,
object resource,
IEnumerable<IAuthorizationRequirement> requirements);
Что позволяет еще более гибко настраивать права доступа. Для демонстрации, используем преопределенный OperationAuthorizationRequirement
(да, этот пример перекочевал в статью прямо с docs.microsoft.com):
public static class Operations
{
public static OperationAuthorizationRequirement Create =
new OperationAuthorizationRequirement { Name = "Create" };
public static OperationAuthorizationRequirement Read =
new OperationAuthorizationRequirement { Name = "Read" };
public static OperationAuthorizationRequirement Update =
new OperationAuthorizationRequirement { Name = "Update" };
public static OperationAuthorizationRequirement Delete =
new OperationAuthorizationRequirement { Name = "Delete" };
}
что позволит вытворять следующие вещи:
_authorizationService.AuthorizeAsync(
User, resource, Operations.Create, Operations.Read, Operations.Update);
В методе HandleRequirementAsync(context, requirement, resource)
соответствующего handler — нужно лишь проверить права соответственно операции, указанной в requirement.Name
и не забыть вызвать context.Fail()
если пользователь провалил авторизацию:
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OperationAuthorizationRequirement requirement,
Order order)
{
string operationName = requirement.Name;
// Проверка, имеет ли пользователь права на действия с заказом
if(true) context.Succeed(requirement);
return Task.CompletedTask;
}
Handler будет вызван столько раз, сколько requirement вы передали в AuthorizeAsync
и проверит каждый requirement по-отдельности. Для единовременной проверки всех прав на операции за один вызов handler — передавайте список операций внутри requirement, например так:
new OperationListRequirement(new[] { Ops.Read, Ops.Update })
На этом обзор возможностей resource-based авторизации закончен, и самое время покрыть наши handler-ы тестами:
[Test]
public async Task MinAgeHandler_WhenCalledWithValidUser_Succeed()
{
var requirement = new MinAgeRequirement(24);
var user = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim> { new Claim("age", "25") }));
var context = new AuthorizationHandlerContext(new [] { requirement }, user, resource: null);
var handler = new MinAgeHandler();
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
Авторизация в Razor-разметке
Выполняемая непосредственно в разметке проверка прав пользователя может быть полезна для скрытия элементов UI, к которым пользователь не должен иметь доступ. Конечно же, во view можно передать все необходимые флаги через ViewModel (при прочих равных я за этот вариант), либо обратиться напрямую к principal через HttpContext.User:
<h4>Возраст: @User.GetClaimValue("age")</h4>
Если вам интересно, то view наследуются от RazorPage класса, а прямой доступ к HttpContext из разметки возможен через свойство @Context
.
С другой стороны, мы можем использовать подход из предыдущего раздела: получить реализацию IAuthorizationService
через DI (да, прямо во view) и проверить пользователя на соответствие требованиям нужной политики:
@inject IAuthorizationService AuthorizationService
@if (await AuthorizationService.AuthorizeAsync(User, "my-policy"))
Не пытайтесь использовать в нашем тестовом проекте вызов SignInManager.IsSignedIn(User)
(используется в шаблоне веб-приложения с типом аутентификации Individual User Accounts). В первую очередь потому, что мы не используем библиотеку аутентификации Microsoft.AspNetCore.Identity
, к которой этот класс принадлежит. Сам метод внутри не делает ничего, помимо проверки наличия у пользователя identity с зашитым в коде библиотеки именем.
Permission-based авторизация. Свой фильтр авторизации
Декларативное перечисление всех запрашиваемых операций (в первую очередь из числа CRUD) при авторизации пользователя, такое как:
var requirement = OperationListRequirement(new[] { Ops.FooAction, Ops.BarAction });
_authorizationService.AuthorizeAsync(User, resource, requirement);
… имеет смысл, если в вашем проекте построена система персональных разрешений (permissions): имеется некий набор из большого числа высокоуровневых операций бизнес-логики, есть пользователи (либо группы пользователей), которым были в ручном режиме выданы права на конкретные операции с конкретным ресурсом. К примеру, у Васи есть права "драить палубу", "спать в кубрике", а Петя может "крутить штурвал". Хорош или плох такой паттерн — тема для отдельной статьи (лично я от него не в восторге). Очевидная проблема данного подхода: список операций легко разрастается до нескольких сотен даже не в самой большой системе.
Ситуация упрощается, если для авторизации нет нужды учитывать конкретный экземпляр защищаемого ресурса, и наша система обладает достаточной гранулярностью, чтобы просто навесить на весь метод атрибут со списком проверяемых операций, вместо сотен вызовов AuthorizeAsync
в защищаемом коде. Однако, использование авторизации на основе политик [Authorize(Policy = "foo-policy")]
приведет к комбинаторному взрыву числа политик в приложении. Почему бы не использовать старую добрую role-based авторизацию? В примере кода ниже, пользователю необходимо быть членом всех указанных ролей для получения доступа к FooController:
[Authorize(Roles = "PowerUser")]
[Authorize(Roles = "ControlPanelUser")]
public class FooController : Controller { }
Подобное решение так же может не дать достаточной детализации и гибкости для системы с большим количеством permissions и их возможных комбинаций. Дополнительные проблемы начинаются, когда нужна и role-based и permission-based авторизация. Да и семантически, роли и операции — разные вещи, хотелось бы обрабатывать их авторизацию отдельно. Решено: пишем свою версию атрибута [Authorize]
! Продемонстрирую конечный результат:
[AuthorizePermission(Permission.Foo, Permission.Bar)]
public IActionResult Edit()
{
return View();
}
Начнем с создания enum для операций, requirement и handler для проверки пользователя:
public enum Permission
{
Foo,
Bar
}
public class PermissionRequirement : IAuthorizationRequirement
{
public Permission[] Permissions { get; set; }
public PermissionRequirement(Permission[] permissions)
{
Permissions = permissions;
}
}
public class PermissionHandler : AuthorizationHandler<PermissionRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PermissionRequirement requirement)
{
//TODO: ваш код проверки, есть ли у пользователя права на эти операции
if (requirement.Permissions.Any())
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Ранее я рассказывал, что атрибут [Authorize]
сугубо маркерный и нужен для применения AuthorizeFilter
. Не будем бороться с существующей архитектурой, поэтому напишем по аналогии собственный фильтр авторизации. Поскольку список permissions у каждого action свой, то:
- Необходимо создавать экземпляр фильтра на каждый вызов;
- Невозможно напрямую создать экземпляр через встроенный DI-контейнер.
К счастью, в Core MVC эти проблемы легко разрешимы при помощи атрибута [TypeFilter]:
[TypeFilter(typeof(PermissionFilterV1), new object[] { new[] { Permission.Foo, Permission.Bar } })]
public IActionResult Index()
{
return View();
}
public class PermissionFilterV1 : Attribute, IAsyncAuthorizationFilter
{
private readonly IAuthorizationService _authService;
private readonly Permission[] _permissions;
public PermissionFilterV1(IAuthorizationService authService, Permission[] permissions)
{
_authService = authService;
_permissions = permissions;
}
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
bool ok = await _authService.AuthorizeAsync(
context.HttpContext.User, null, new PermissionRequirement(_permissions));
if (!ok) context.Result = new ChallengeResult();
}
}
Мы получили полностью работающее, но безобразно выглядящее решение. Для того, чтобы скрыть детали реализации нашего фильтра от вызывающего кода, нам и пригодится атрибут [AuthorizePermission]
:
public class AuthorizePermissionAttribute : TypeFilterAttribute
{
public AuthorizePermissionAttribute(params Permission[] permissions)
: base(typeof(PermissionFilterV2))
{
Arguments = new[] { new PermissionRequirement(permissions) };
Order = Int32.MaxValue;
}
}
Результат:
[AuthorizePermission(Permission.Foo, Permission.Bar)]
[Authorize(Policy = "foo-policy")]
public IActionResult Index()
{
return View();
}
Обратите внимание: фильтры авторизации работают независимо, что позволяет сочетать их друг с другом. Порядок выполнения нашего фильтра в общей очереди можно скорректировать при помощи свойства AuthorizePermissionAttribute.Order
.
Дополнительные материалы для чтения по теме (также приветствуются ваши ссылки для включения в список):
- Официальная документация на docs.microsoft.com.
- Цикл статей по безопасности ASP.NET Core в блоге Andrew Lock | .NET Escapades. Интересный блог в целом.
На этом обзор авторизации в ASP.NET Core MVC завершен. Большая часть материала применима и к WebAPI. Желающим воспроизвести примеры из статьи я рекомендую воспользоваться демонстрационным проектом. В следующей статье (я надеюсь) мы защитим веб-сайт и публичный API при помощи выделенного сервера аутентификации.