Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Override authorization policy in ASP.NET Core 3

EDIT: This has been solved. Please see my EDIT below. For a more "default" solution where you do not need a "ignore the claim completely" override, see the accepted answer. At the time of writing, My EDIT below contains code that helps you support another scenario where you want to completely ignore the requirement instead of override it.

I have seen multiple posts on this issue but none really solve my problem. One promising one sadly did not work for me.

I have some some policies. The default one requires the existence of a claim. But another policy requires that this claim does NOT exist. If the default one is applied on the controller, I can not apply the other one on the method. Instead of overriding my previous policy, the policies are all collected together and the first policy fails because the claim is not available.

A good example:

Startup:

services.AddAuthorization(options =>
{
    // 99% of the methods require you to have this claim, so we set this as default.
    options.DefaultPolicy = new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
        .RequireAuthenticatedUser()
        .RequireClaim("UserId")
        .Build();

    options.AddPolicy("UnregisteredUsers",
        new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
            .RequireAuthenticatedUser() // You DO need to be authorized, but that does not mean you already have an account!
            .RequireAssertion(x => x.User.FindUserIdClaim() == null) // If you do not have a user ID, you can not access endpoints that require you to be registered until you have an ID
unregistered.
            .Build());

    // A more specific policy
    options.AddPolicy("Administrator",
        new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
            .RequireAuthenticatedUser()
            .RequireRole(Roles.Administrator)
            .Build());
});

Controllers:

[Route("api/[controller]")]
[ApiController]
[Authorize] // You could also use MapControllers().RequireAuthorization() in Startup.cs instead of [Authorize]
public class BaseController : ControllerBase
{

}

public class UsersController : BaseController
{

    // You can only access this endpoint IF you do not have an account.
    [HttpPost]
    [Authorize(Policy = "UnregisteredUsers")]
    public async Task<IActionResult> CreateAccount()
    {
       // Code here
       return Ok();
    }
    

    // This one uses the default policy, just like many other policies
    [HttpGet]
    public async Task<IActionResult> GetUsers()
    {
       // This would throw if unregistered users would access this endpoint because the claim is not set.
       var userId = User.GetUserIdClaim();
      
       // Other code here
       return Ok();      
    }
}

As you can see, if you were to try to create an account, the basecontroller would already deny you access because you do not have that claim. But I require the claim to NOT exist, so this is not a useful comment. Basically, my 2nd policy is more important than the first one.

I hope you guys can help me out!


EDIT:

Thanks to @King King I have been able to fix this. Their answer works, but I needed to make some changes for some specific scenario's:

  • The order is apparently not guaranteed.
  • The answer works when you want to "override" an existing policy by "turning it around". In this case, by default I require a user ID, but another policy requires that you DO not have it. But a scenario that is not supported out of the box is when you have a policy that does not care if you have user ID or not. I will post my FULL solution here but I am very grateful for @King King for his help!
// Note: Scope is REQUIRED!
services.AddScoped<IAuthorizationHandler, UserIdClaimRequirementHandler>();

services.AddAuthorization(options =>
{
    // 99% of the methods require you to have this claim, so we set this as default.
    options.DefaultPolicy = new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
        .RequireAuthenticatedUser()
        .AddRequirements(new UserIdClaimRequirement(UserIdClaimSetting.ClaimMustExist))
        .Build();

    options.AddPolicy("UnregisteredUsers",
        new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
            .RequireAuthenticatedUser() // You DO need to be authorized, but that does not mean you already have an account!
            .AddRequirements(new UserIdClaimRequirement(UserIdClaimSetting.ClaimMustNotExist)) // If you do not have a user ID, it means you are not in the database yet, which means you are unregistered.
            .Build());

    // Registed users and Unregistered users can access these endpoints, but they DO need to be authenticated.
    options.AddPolicy("AllUsers",
        new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
            .RequireAuthenticatedUser()
            .AddRequirements(new UserIdClaimRequirement(UserIdClaimSetting.IgnoreClaim))      
            .Build());

    // A more specific policy. This builds upon the default policy, so a user ID is required. Technically we could also omit the RequireAuthenticatedUser() call but I like that this is explicit.
    options.AddPolicy("Administrator",
        new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
            .RequireAuthenticatedUser()
            .RequireRole(Roles.Administrator)
            .Build());
});

Requirement class:

/// <summary>
/// Can be used to configure the requirement of the existence of the UserId claim.
/// Either it must exist, it must NOT exist or it does not matter. <br/><br/>
/// 
/// Please note: Order is not guaranteed with policies. <br/>
/// In the case when you use a <see cref="UserIdClaimSetting.IgnoreClaim"/> or <see cref="UserIdClaimSetting.ClaimMustNotExist"/> and the default policy uses a <see cref="UserIdClaimSetting.ClaimMustExist"/>,
/// the handler should prioritize the result of the <see cref="UserIdClaimSetting.IgnoreClaim"/> and <see cref="UserIdClaimSetting.ClaimMustNotExist"/> 
/// and just not evaluate <see cref="UserIdClaimSetting.ClaimMustExist"/> to prevent it returning 403 when that one runs last.
/// In order to do so, the handler MUST be registered as Scoped so it resets for the next reset.
/// </summary>
/// <remarks>
/// This is quite a silly solution! 
/// The reason it is necessary is because policies are built on top of each other.
/// The default policy requires that the claim exists because this is true for 99% of the requests, 
/// so it makes sense to make this the default to prevent having to explicitly setup authorization on each endpoint. <br/><br/>
/// The "UnregisteredUsers" policy requires that it does NOT exist.<br/><br/>
/// The "AllUsers" policy does not care if it exists or not. 
/// Your first thought is probably that using this requirement would be unnecessary in that case,
/// but if this requirement is not used there, the default policy's requirement will require the existence of the claim which will break this policy.
/// </remarks>
public class UserIdClaimRequirement : IAuthorizationRequirement
{
    public UserIdClaimSetting Setting { get; }

    public UserIdClaimRequirement(UserIdClaimSetting setting)
    {
        Setting = setting;
    }
}

public enum UserIdClaimSetting
{
    /// <summary>
    /// If the claim does <b>not</b> exist, authorization will fail
    /// </summary>
    ClaimMustExist,
    /// <summary>
    /// If the claim exists, authorization will fail
    /// </summary>
    ClaimMustNotExist,
    /// <summary>
    /// It does not matter if the claim exists. Either way, authorization will succeed.
    /// </summary>
    IgnoreClaim
}

Requirement Handler:

public class UserIdClaimRequirementHandler : AuthorizationHandler<UserIdClaimRequirement>
{
    /// <summary>
    /// Order is not guaranteed with policies. 
    /// In the case when you use a <see cref="UserIdClaimSetting.IgnoreClaim"/> or <see cref="UserIdClaimSetting.ClaimMustNotExist"/> and the default policy uses a <see cref="UserIdClaimSetting.ClaimMustExist"/>,
    /// the handler should prioritize the result of the <see cref="UserIdClaimSetting.IgnoreClaim"/> and <see cref="UserIdClaimSetting.ClaimMustNotExist"/> 
    /// and just not evaluate <see cref="UserIdClaimSetting.ClaimMustExist"/> to prevent it returning 403 when that one runs last.
    /// In order to do so, the handler MUST be registered as Scoped so it resets for the next reset.
    /// </summary>
    private bool _policyHasAlreadySucceeded = false;

    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, UserIdClaimRequirement requirement)
    {
        if(_policyHasAlreadySucceeded)
        {
            return Task.CompletedTask;
        }

        var hasUserId = context.User.FindFirst("UserId") != null;
        // If the claim must not exist but it does  -> FAIL
        // If the claim must exist but it does not  -> FAIL
        // If it doesn't matter if the claim exists -> SUCCEED
        if ((requirement.Setting == UserIdClaimSetting.ClaimMustNotExist && hasUserId) ||
            (requirement.Setting == UserIdClaimSetting.ClaimMustExist && !hasUserId) ||
            (requirement.Setting == UserIdClaimSetting.IgnoreClaim && false))
        {
            context.Fail();
        }
        else
        {
            // This requirement has succeeded!

            _policyHasAlreadySucceeded = requirement.Setting == UserIdClaimSetting.IgnoreClaim || requirement.Setting == UserIdClaimSetting.ClaimMustNotExist;

            // Also, if there are other policy requirements that use the UserId claim, just set them to SUCCEEDED because this requirement is more important than those.
            // Example: The default policy requires you to have a user id claim, while this requirement might be used by requiring the claim to NOT exist.
            // In order to make this work, we have to override the "require user id claim" requirement by telling it that it succeeded even though it did not!
            var otherUserIdClaimRequirements = context.Requirements.Where(e => e is UserIdClaimRequirement || e is ClaimsAuthorizationRequirement cu && cu.ClaimType == "UserId");
            foreach (var r in otherUserIdClaimRequirements)
            {
                context.Succeed(r);
            }
        }
        return Task.CompletedTask;
    }
}
like image 242
S. ten Brinke Avatar asked Jan 13 '21 15:01

S. ten Brinke


People also ask

How would you apply an authorization policy to a controller in an ASP.NET Core application?

Role-Based Authorization in ASP.NET Core You can specify what roles are authorized to access a specific resource by using the [Authorize] attribute. You can even declare them in such a way that the authorization evaluates at the controller level, action level, or even at a global level. Let's take Slack as an example.

What is AuthorizationHandlerContext?

The AuthorizationHandlerContext class is what the handler uses to mark whether requirements have been met: C# Copy. context.Succeed(requirement) The following code shows the simplified (and annotated with comments) default implementation of the authorization service: C# Copy.

What is IHttpContextAccessor?

IHttpContextAccessor Interface (Microsoft.AspNetCore.Http)Provides access to the current HttpContext, if one is available.


1 Answers

The authorization requirement handlers are ANDed. So if any failed, the whole will fail. The authorization policies will be transformed into a set of authorization requirement handlers. Per my debugging, there are 2 authorization requirements transformed from the default policy (in your code) namely DenyAnonymousAuthorizationRequirement (corresponding to RequireAuthenticatedUser()) and ClaimsAuthorizationRequirement with ClaimType = "UserId" (corresponding to RequireClaim("UserId")).

I've found myself one way to override the result (or simply skip, I'm not so sure about this) of the handlers that handle those 2 requirements. That is by implement a custom requirement handler in which you have access to the AuthorizationHandlerContext which exposes all the authorization requirements that need to be handled (of course including the 2 those I mentioned above). By calling Succeed on them, they seem to be ignored (from being handled again or simply skipped). We can add our second custom authorization requirement handler to verify that but it's not important at all (so I did not do it).

Here is how you build your custom policy (UnregisteredUsers) using a custom authorization requirement handler instead of basing on RequireAssertion:

//the custom requirement class which must implement IAuthorizationRequirement
public class NoUserIdClaimRequirement : IAuthorizationRequirement
{
}
//the corresponding handler
public class NoUserIdClaimRequirementHandler : AuthorizationHandler<NoUserIdClaimRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, NoUserIdClaimRequirement requirement)
    {
        var hasUserId = context.User.FindFirst("UserId") != null;
        if (hasUserId)
        {
            context.Fail();                
        } else
        {
            //NOTE: if you're sure about the extremely high priority of this requirement 
            //(so we can discard/ignore all the other requirements), just remove the .Where for a shorter code
            foreach (var r in context.Requirements
                                     .Where(e => e is NoUserIdClaimRequirement ||
                                                 e is ClaimsAuthorizationRequirement cu && cu.ClaimType == "UserId"))
            {
                //mark all as succeeded
                context.Succeed(r); 
            }
        }
        return Task.CompletedTask;
    }
}

In the code above, I understand that the user still need to be authenticated so we don't call Succeed for DenyAnonymousAuthorizationRequirement. If that's not true, you can include it in the filter above.

You need to register the authorization requirement handler type in ConfigureServices:

services.AddSingleton<IAuthorizationHandler, NoUserIdClaimRequirementHandler>();

Now instead of using RequireAssertion, you need to build your custom policy like this:

options.AddPolicy("UnregisteredUsers",
                  x => x.RequireAuthenticatedUser()
                        .AddRequirements(new NoUserIdClaimRequirement()));

I've tried a simple demo on my own, which works perfectly. But it may need some tweak from your own side. If any error occurs, please let me know. The code here is just to show the idea about a possible way to solve this issue. You can base on that to build a more complicated & general solution.

like image 125
King King Avatar answered Oct 26 '22 22:10

King King