Search code examples
c#asp.net-core-2.1

How to check custom attributes inside authorization process (policy or middleware)?


Main goal is to prevent access to the portal when OIDC user has custom claim with type 'BlockedFrom', which added in ClaimsTransformation.

I've solved it by middleware in Startup.Configure method. General reason is to keep original request URL without redirection to /Account/AccessDenied page.

app.Use((context, next) =>
{
    var user = context.User;

    if (user.IsAuthenticated())
    {
        // Do not rewrite path when it marked with custom [AllowBlockedAttribute]!
        // /Home/Logout, for example. But how?
        //
        if (user.HasClaim(x => x.Type == UserClaimTypes.BlockedFrom))
        {
            // Rewrite to run specific method of HomeController for blocked users
            // with detailed message.
            //
            context.Request.Path = GenericPaths.Blocked;
        }
    }

    return next();
});

But have one unexpected result: the Logout method of HomeController is blocked too. User can't logout when blocked, hah! The first thing that came to mind - check custom attribute such like [AllowBlockedAttribute]. Hardcoded path constants in middleware looks crazy. How to access attributes of calling method in middleware?

Another (and more elegant) way is to put this logic to custom BlockedHandler : AuthorizationHandler<BlockedRequirement> and assign it in MVC options of Startup.ConfigureServices method as general policy:

services.AddSingleton<IAuthorizationHandler, BlockedHandler>();

services.AddMvc(options =>
{
    var policy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .AddRequirements(new BlockedRequirement())
        .Build();

    // Set the default authentication policy to require users to be authenticated.
    //
    options.Filters.Add(new AuthorizeFilter(policy));

}).SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

Hypothetical implementation of BlockedHandler:

public class BlockedHandler : AuthorizationHandler<BlockedRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, BlockedRequirement requirement)
    {
        if (!context.User.HasClaim(c => c.Type == UserClaimTypes.BlockedFrom))
        {
            context.Succeed(requirement);
            return Task.CompletedTask;
        }

        // User is blocked!

        if (context.Resource is AuthorizationFilterContext mvcContext)
        {
            if (mvcContext.ActionDescriptor is ControllerActionDescriptor descriptor)
            {
                var allowBlocked = descriptor.ControllerTypeInfo.CustomAttributes
                    .Concat<CustomAttributeData>(descriptor.MethodInfo.CustomAttributes)
                    .Any(x => x.AttributeType == typeof(AllowBlockedAttribute));

                // User can access called action.
                //
                if (allowBlocked)
                    context.Succeed(requirement);
            }

            // Ugly to call this as the next step?
            // mvcContext.HttpContext.Request.Path = GenericPaths.Blocked;
        }

        // Prevent redirection to AccessDenied
        // Stop authorization chain.

        return Task.CompletedTask;
    }
}

Ok, now we can handle custom attribute. Seems that AuthorizationHandler is not a best place to tell HttpContext to change it's RequestPath without redirection. Where it can be done?


Solution

  • I've done some digging in the framework sources and found a way to make this work in the authorization handler way.

    The entry point to the authorization process is AuthorizeFilter. The filter context has a Result property accepting an IActionResult. By setting this property you can short-circuit the request and display whatever action result (including a view) you want. This is the key to the solution.

    If you follow the execution path, you realize that the filter context is passed to the authorization components and is available in the IAuthorizationHandler.HandleRequirementAsync method. You can get it from the Resource property of the context object by a downcast (as showed by OP already).

    There's one more important thing: you must return success from the authorization handler, otherwise you end up with a redirect inevitably. (This becomes clear if you check out the default implementation of IPolicyEvaluator.)

    So putting this all together:

    public class BlockedHandler : AuthorizationHandler<BlockedRequirement>
    {
        private Task HandleBlockedAsync(AuthorizationFilterContext filterContext)
        {
            // create a model for the view if needed...
            var model = new BlockedModel();
    
            // do some processing if needed...
    
            var modelMetadataProvider = filterContext.HttpContext.RequestServices.GetService<IModelMetadataProvider>();
            // short-circuit request by setting the action result
            filterContext.Result = new ViewResult
            {
                StatusCode = 403, // Client cannot access the requested resource
    
                ViewName = "~/Views/Shared/Blocked.cshtml",
                ViewData = new ViewDataDictionary(modelMetadataProvider, filterContext.ModelState) { Model = model }
            };
    
            return Task.CompletedTask;
        }
    
        protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, BlockedRequirement requirement)
        {
            if (context.User.HasClaim(c => c.Type == UserClaimTypes.BlockedFrom) && 
                context.Resource is AuthorizationFilterContext filterContext &&
                filterContext.ActionDescriptor is ControllerActionDescriptor descriptor)
            {
                var allowBlocked = descriptor.ControllerTypeInfo.CustomAttributes
                    .Concat(descriptor.MethodInfo.CustomAttributes)
                    .Any(x => x.AttributeType == typeof(AllowBlockedAttribute));
    
                if (!allowBlocked)
                    await HandleBlockedAsync(filterContext);
            }
    
            // We must return success in every case to avoid forbid/challenge.
            context.Succeed(requirement);
        }
    }