2 марта в 13:54

Авторизация в ASP.NET Core MVC


Logo designed by Pablo Iglesias.


В статье описаны паттерны и приемы авторизации в ASP.NET Core MVC. Подчеркну, что рассматривается только авторизация (проверка прав пользователя) а не аутентификация, поэтому в статье не будет использования ASP.NET Identity, протоколов аутентификации и т.п. Будет много примеров серверного кода, небольшой экскурс вглубь исходников Core MVC, и тестовый проект (ссылка в конце статьи). Приглашаю интересующихся под кат.


Содержание:




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. Это всё, что нужно для нашего тестирования.


Если хочется использовать ASP.NET Identity с in-memory хранилищем пользователей

Для эмуляции хранилища пользователей достаточно открыть 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, который в свою очередь будет прикреплен к ответу веб-сервера и сохранен на стороне клиента:


Создание principal-заглушки при входе пользователя на сайт
[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:


  1. Из списка политик выбирается нужная на основе указанного в атрибуте [Authorize] значения (либо берется AuthorizationPolicy — политика по-умолчанию, содержащая всего одно требование с говорящим названием — DenyAnonymousAuthorizationRequirement.
  2. Выполняется проверка, соответствует ли набор из 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-контейнер:


Пример requirement и handler
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 свой, то:


  1. Необходимо создавать экземпляр фильтра на каждый вызов;
  2. Невозможно напрямую создать экземпляр через встроенный DI-контейнер.

К счастью, в Core MVC эти проблемы легко разрешимы при помощи атрибута [TypeFilter]:


[TypeFilter(typeof(PermissionFilterV1), new object[] { new[] { Permission.Foo, Permission.Bar } })]
public IActionResult Index()
{
    return View();
}

PermissionFilterV1
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.


Дополнительные материалы для чтения по теме (также приветствуются ваши ссылки для включения в список):



На этом обзор авторизации в ASP.NET Core MVC завершен. Большая часть материала применима и к WebAPI. Желающим воспроизвести примеры из статьи я рекомендую воспользоваться демонстрационным проектом. В следующей статье (я надеюсь) мы защитим веб-сайт и публичный API при помощи выделенного сервера аутентификации.

Илья @chumakov-ilya
карма
24,5
рейтинг 31,9
.NET backend developer
Похожие публикации
Самое читаемое Разработка

Комментарии (4)

  • +1
    Спасибо за статью, чувствую, когда-то буду переходить на бэкенд-разработку, очень пригодится.
  • +1

    Спасибо за статью! В целом очень интересно. Но в своем проекте я атрибуты использую для разрешения доступа всем, а без атрибутов у меня контроллер закрыт пользователям правилами. То есть публичные странички открываю через атрибут. Голый контроллер (без атрибута) ни кому ни чего не отдаст на просмотр, если это не разрешено в глобальном фильтре.


    Но, на мой взгляд, есть какая то непродуманность с атрибутами контроллеров в виде фильтров.
    Вот допустим у меня будут множиться как грибы странички с уникальным содержимым в виде отчетов которые должны заполнять люди. Каждый отчет будет содержать свою группу пользователей. К отчету будут создаваться группы пользователей с правами на редактирование строк и столбцов. Я напишу уникальный фильтр, а новый разработчик забудет его проставить в контроллер? И что потом будет? Мне что, еще и генерацию контроллеров мастером переписывать?
    Атрибуты это какое то временное решение на мой взгляд.

    • 0

      Точно также разработчик может забыть проставить "разрешающий" атрибут, и обе ситуации проверяются code review и тестами. Атрибут любого такого типа над защищаемым action означает некоторое изменение (усиление, либо ослабление) политики, принятой по-умолчанию. Безусловно, политика по-умолчанию может быть "запрещать всё", и зависит от конкретного приложения: стоит учесть и безопасность, и удобство разработки (чем меньше ручной работы — тем лучше).

  • –2

    Спасибо — очень интересно !

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