Пользовательские политики и требования авторизации в ASP.NET Core

  • Михаил
  • 12 мин. на прочтение
  • 127
  • 05 Dec 2022
  • 05 Dec 2022

Этот пост является следующим в серии постов об инфраструктуре аутентификации и авторизации в ASP.NET Core. В предыдущем посте мы показали базовую структуру для авторизации в ASP.NET Core, т. е. ограничение доступа к частям вашего приложения в зависимости от текущего аутентифицированного пользователя. Мы представили концепцию политик, чтобы отделить вашу логику авторизации от основных ролей и утверждений пользователей. Наконец, мы показали, как создавать простые политики, проверяющие существование одного утверждения или роли.

В этом посте мы рассмотрим создание более сложных политик с несколькими требованиями, создание настраиваемого требования и применение политики авторизации ко всему приложению.

Политики с несколькими требованиями

В предыдущем посте я показал, как мы можем создать простую политику, названную CanAccessVIPAreaдля проверки того, разрешен ли пользователю доступ к методам, связанным с VIP. Эта политика проверена на одно требование к Пользователю и авторизует пользователя, если политика удовлетворена. Для полноты, вот как мы настроили его в нашем Startupклассе:

public void ConfigureServices(IServiceCollection services)  
{
    services.AddMvc();

    services.AddAuthorization(options =>
    {
        options.AddPolicy(
            "CanAccessVIPArea",
            policyBuilder => policyBuilder.RequireClaim("VIPNumber"));
    });
}

Представьте теперь, что исходные требования изменились. Например, рассмотрим применение этой политики к VIP-залу в аэропорту. В текущей реализации вам будет позволено войти, только если у вас есть VIP-номер. Однако теперь мы хотим добиться того, чтобы в VIP-зал могли пользоваться также сотрудники авиакомпании, а также генеральный директор аэропорта.

При первом рассмотрении проблемы вы можете увидеть policyBuilderвышеприведенный объект, заметить, что он предоставляет гибкий интерфейс, и у вас возникнет соблазн связать с ним дополнительные RequireClaim()вызовы, что-то вроде

policyBuilder => policyBuilder
    .RequireClaim("VIPNumber")
    .RequireClaim("EmployeeNumber")
    .RequireRole("CEO"));

К сожалению, это не приведет к желаемому поведению. Каждое из требований, составляющих политику, должно быть удовлетворено, т. е. они объединяются с помощью И, тогда как у нас есть требование ИЛИ. Чтобы пройти политику в этом текущем состоянии, вам нужно иметь VIPNumber, EmployeeNumber, а также быть генеральным директором!

Создание пользовательской политики с помощью Func

Существует ряд различных подходов, доступных для удовлетворения наших бизнес-требований, но, поскольку в этом случае политику просто выразить, мы просто будем использовать Func<AuthorizationHandlerContext, bool>предоставленный PolicyBuilder.RequireAssertionметод:

services.AddAuthorization(options =>
{
    options.AddPolicy(
        "CanAccessVIPArea",
        policyBuilder => policyBuilder.RequireAssertion(
            context => context.User.HasClaim(claim => 
                           claim.Type == "VIPNumber" 
                           || claim.Type == "EmployeeNumber")
                        || context.User.IsInRole("CEO"))
        );
});

Чтобы удовлетворить это требование, мы возвращаем простой bool, чтобы указать, авторизован ли пользователь на основе политики. Нам предоставляется an, AuthorizationHandlerContextкоторый предоставляет нам доступ к текущему ClaimsPrincipalчерез Userсвойство. Это позволяет нам проверить утверждения и роль пользователя.

Как видно из нашей логики, наша "CanAccessVIPArea"политика теперь будет авторизовываться, если выполняются какие-либо из наших первоначальных бизнес-требований, что обеспечивает несколько способов авторизации пользователя.

Создание пользовательского требования

Хотя описанный выше подход работает для самых простых требований, легко заметить, что по мере усложнения правил код вашей политики может быстро стать неуправляемым. Кроме того, вам может понадобиться доступ к другим службам через внедрение зависимостей. В этих случаях стоит подумать о создании пользовательских требований и обработчиков.

Прежде чем я перейду к коду, кратко повторим используемую здесь терминологию:

  • У нас есть ресурс , который необходимо защитить (например, действие MVC), чтобы только некоторые пользователи могли получить к нему доступ,
  • Ресурс может быть защищен одной или несколькими политиками (например , CanAccessVIPArea ). Все политики должны быть удовлетворены, чтобы доступ к ресурсу был предоставлен.
  • Каждая политика имеет одно или несколько требований (например , IsVIP , IsBookedOnToFlight ). Все требования должны быть удовлетворены в политике, чтобы политика в целом была удовлетворена.
  • У каждого требования есть один или несколько обработчиков . Требование считается выполненным, если любой из них возвращает Successрезультат, и ни один из них не возвращает явный Failрезультат.

Имея это в виду, мы изменим нашу политику VIP, чтобы использовать настраиваемое требование, и создадим для него несколько обработчиков.

Требование

Требованием в ASP.NET Core является простой класс, реализующий интерфейс пустого маркера IAuthorizationRequirement. Вы также можете использовать его для хранения любых дополнительных параметров для последующего использования. Мы расширили наше базовое VIP-требование, описанное ранее, и теперь также предоставляем Airline, так что мы разрешаем доступ в VIP-зал только сотрудникам данной авиакомпании:

public class IsVipRequirement : IAuthorizationRequirement
{
    public IsVipRequirement(string airline)
    {
        Airline = airline;
    }

    public string Airline { get; }
}

Обработчики авторизации

Обработчик авторизации — это место, где выполняется вся работа по авторизации требования. Чтобы реализовать обработчик, наследуемый от AuthorizationHandler<T>, и реализуйте HandleRequirementAsync()метод. Как упоминалось ранее, у требования может быть несколько обработчиков, и только один из них должен быть успешным, чтобы требование было удовлетворено.

В нашем бизнес-требовании у нас есть три обработчика, соответствующие трем различным способам удовлетворения требования. Каждый из них представлен и объяснен ниже. Я также добавлю дополнительный обработчик, который проверяет, был ли пользователь ранее заблокирован в VIP-зале, поэтому его нельзя снова впускать!

Самый простой обработчик — это обработчик CEO. Это просто проверяет, находится ли текущий аутентифицированный пользователь в роли "CEO". Если да, то обработчик вызывает Succeedбазовое требование. Задача по умолчанию возвращается в конце метода, поскольку метод является асинхронным. Обратите внимание, что в случае невыполнения требования мы ничего не делаем с контекстом ; если не удается выполнить его с текущим обработчиком, мы оставляем его для обработки следующим обработчиком.

public class IsCEOAuthorizationHandler : AuthorizationHandler<IsVipRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IsVipRequirement requirement)
    {
        if (context.User.IsInRole("CEO"))
        {
            context.Succeed(requirement);
        }
        return Task.FromResult(0);
    }
}

Обработчик VIP-номера почти такой же, он выполняет простую проверку того, что текущий ClaimsPrincipalсодержит требование типа "VIPNumber"и, если да, удовлетворяет требованиям.

public class HasVIPNumberAuthorizationHandler : AuthorizationHandler<IsVipRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IsVipRequirement requirement)
    {
        if (context.User.HasClaim(claim => claim.Type == "VIPNumber"))
        {
            context.Succeed(requirement);
        }
        return Task.FromResult(0);
    }
}

Наш следующий обработчик — обработчик «сотрудник». Это подтверждает, что у аутентифицированного пользователя есть утверждение типа «EmployeeNumber», а также то, что это утверждение было выдано данной авиакомпанией. Вскоре мы увидим, откуда берется переданный объект требования, но вы можете видеть, что мы можем получить доступ к его Airlineсвойству и использовать его в нашем обработчике:

public class IsAirlineEmployeeAuthorizationHandler : AuthorizationHandler<IsVipRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IsVipRequirement requirement)
    {
        if (context.User.HasClaim(claim =>
            claim.Type == "EmployeeNumber" && claim.Issuer == requirement.Airline))
        {
            context.Succeed(requirement);
        }
        return Task.FromResult(0);
    }
}

Наш последний обработчик имеет дело со случаем, когда пользователю было запрещено быть VIP (может быть, он украл слишком много крошечных тюбиков зубной пасты или съел один, два, много Laphroaig). Даже если другие требования соблюдены, мы не хотим предоставлять аутентифицированному пользователю VIP-статус. Таким образом, даже если пользователь является генеральным директором, имеет VIP-номер и является сотрудником, если он забанен, он не может войти.

Мы можем запрограммировать это бизнес-требование, вызвав соответствующий context.Fail()метод внутри HandleRequirementAsyncметода:

public class IsBannedAuthorizationHandler : AuthorizationHandler<IsVipRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IsVipRequirement requirement)
    {
        if (context.User.HasClaim(claim => claim.Type == "IsBannedFromVIP"))
        {
            context.Fail();
        }
        return Task.FromResult(0);
    }
}

Вызов Fail()переопределяет любые другие Success()вызовы для требования. Обратите внимание, что независимо от того, вызывает ли обработчик Successили Fail, будут вызваны все зарегистрированные обработчики. Это гарантирует, что любые побочные эффекты (например, ведение журнала и т. д.) всегда будут выполняться, независимо от порядка запуска обработчиков.

Проводка все это

Теперь у нас есть все, что нам нужно, нам просто нужно подключить политику и обработчики. Мы модифицируем конфигурацию нашего AddAuthorizationвызова, чтобы использовать наш IsVipRequirement, а также регистрируем наши обработчики в контейнере внедрения зависимостей. Здесь мы можем использовать Singleton, так как мы не внедряем никаких зависимостей.

public void ConfigureServices(IServiceCollection services)  
{
    services.AddMvc();

    services.AddAuthorization(options =>
    {
        options.AddPolicy(
            "CanAccessVIPArea",
            policyBuilder => policyBuilder.AddRequirements(
                new IsVipRequirement("British Airways"));
    });

   services.AddSingleton<IAuthorizationHandler, IsCEOAuthorizationHandler>();
   services.AddSingleton<IAuthorizationHandler, HasVIPNumberAuthorizationHandler>();
   services.AddSingleton<IAuthorizationHandler, IsAirlineEmployeeAuthorizationHandler>();
   services.AddSingleton<IAuthorizationHandler, IsBannedAuthorizationHandler>();
}

Здесь важно отметить, что мы явно создаем экземпляр, IsVipRequirementкоторый будет связан с этой политикой. Это означает, что "CanAccessVIPArea"политика распространяется только на "British Airways"сотрудников. Если бы мы хотели аналогичного поведения для "American Airlines"сотрудников, нам нужно было бы создать второй файл Policy. Именно этот IsVipRequirementобъект передается HandleRequirementAsyncметоду в наших обработчиках.

Имея нашу политику, мы можем легко применить ее в нескольких местах с помощью AuthorizeAttributeи защитить наши методы действий:

public class VIPLoungeControllerController : Controller
{
    [Authorize("CanAccessVIPArea")]
    public IActionResult ViewTheFancySeatsInTheLounge()
    {
       return View();
    }

Применение требования глобальной авторизации

Помимо применения политики к отдельным действиям или контроллерам, вы также можете применять политики глобально для защиты всех ваших конечных точек MVC. Классическим примером этого является то, что вы всегда хотите, чтобы пользователь аутентифицировался для просмотра вашего сайта. Вы можете легко создать политику для этого, используя RequireAuthenticatedUser()метод на PolicyBuilder, но как применить политику глобально?

Для этого вам нужно добавить AuthorizeFilterк глобальным фильтрам MVC как часть вашего вызова AddMvc(), передав сконструированный Policy:

services.AddMvc(config =>
{
    var policy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
    config.Filters.Add(new AuthorizeFilter(policy));
});

Как показано в предыдущем посте , именно AuthorizeFilterздесь происходит работа по авторизации в приложении MVC, и она добавляется везде, где AuthorizeAttributeиспользуется . В этом случае мы гарантируем добавление AuthorizeFilterдополнительного для каждого запроса.

Обратите внимание, что, поскольку это происходит для каждого действия, вам нужно будет украсить свои методы входа и т. д. AllowAnonymousатрибутом, чтобы вы могли фактически аутентифицироваться и просматривать остальную часть сайта!

Резюме

В этом посте я более подробно показал, как политики авторизации, требования и обработчики работают в ASP.NET Core. Я показал, как можно использовать Func<>для обработки простых политик и как создавать собственные требования и обработчики для более сложных политик. Наконец, я показал, как можно применить политику глобально ко всему приложению MVC.