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

    Метки:
    • +28
    • 17,8k
    • 4
    Поделиться публикацией
    Похожие публикации
    Комментарии 4
    • +1
      Спасибо за статью, чувствую, когда-то буду переходить на бэкенд-разработку, очень пригодится.
      • +1

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


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

        • 0

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

        • –2

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

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