
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 при помощи выделенного сервера аутентификации.
