Custom authorization filter not working in ASP.NET Core 3

Lukas

I'm working on adding authentication (and eventually authorization) with AzureAD to an ASP.NET Core 3.1 app using a custom authorization attribute filter. The code below implements the IAuthorizationFilter's OnAuthorization method within which I redirect the user to the SignIn page when their authentication expires.

When a controller action with [CustomAuthorizationFilter] is hit I expect the attribute's OnAuthorization method to be hit right away whether or not the authentication cookie has expired.

That expectation doesn't happen and instead if a user is not authenticated and a controller action is hit, user is automatically reauthenticated with Microsoft and a valid cookie is created, and only then the OnAuthorization method is hit, defeating what I thought was the purpose of the OnAuthorization method.

I've been doing a lot of research to understand this behavior, but I'm clearly missing something. The most useful piece of information I found was in Microsoft docs:

As of ASP.NET Core 3.0, MVC doesn't add AllowAnonymousFilters for [AllowAnonymous] attributes that were discovered on controllers and action methods. This change is addressed locally for derivatives of AuthorizeAttribute, but it's a breaking change for IAsyncAuthorizationFilter and IAuthorizationFilter implementations.

So, it appears that implementations with IAuthorizationFilter may be broken in 3.0+ and I don't know how to fix it.

Is this behavior normal or is my implementation incorrect?

If normal, why am I reauthenticated before the OnAuthorization method runs?

If incorrect, how can I implement it correctly?

CustomAuthorizationFilter.cs

public class CustomAuthorizationFilter : AuthorizeAttribute, IAuthorizationFilter
{
    public void OnAuthorization(AuthorizationFilterContext context)
    {
        string signInPageUrl = "/UserAccess/SignIn";

        if (context.HttpContext.User.Identity.IsAuthenticated == false)
        {
            if (context.HttpContext.Request.IsAjaxRequest())
            {
                context.HttpContext.Response.StatusCode = 401;
                JsonResult jsonResult = new JsonResult(new { redirectUrl = signInPageUrl });
                context.Result = jsonResult;
            }
            else
            {
                context.Result = new RedirectResult(signInPageUrl);
            }
        }
    }
}

The IsAjaxRequest() extension used:

//Needed code equivalent of Request.IsAjaxRequest().
//Found this solution for ASP.NET Core: https://stackoverflow.com/questions/29282190/where-is-request-isajaxrequest-in-asp-net-core-mvc
//This is the one used in ASP.NET MVC 5: https://github.com/aspnet/AspNetWebStack/blob/master/src/System.Web.Mvc/AjaxRequestExtensions.cs
public static class AjaxRequestExtensions
{
    public static bool IsAjaxRequest(this HttpRequest request)
    {
        if (request == null)
        {
            throw new ArgumentNullException("request");
        }

        if (request.Headers != null)
        {
            return (request.Headers["X-Requested-With"] == "XMLHttpRequest");
        }

        return false;
    }
}

AzureAD authentication implementation in Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    IAppSettings appSettings = new AppSettings();
    Configuration.Bind("AppSettings", appSettings);

    services.AddAuthentication(AzureADDefaults.AuthenticationScheme)
        .AddAzureAD(options =>
        {
            options.Instance = appSettings.Authentication.Instance;
            options.Domain = appSettings.Authentication.Domain;
            options.TenantId = appSettings.Authentication.TenantId;
            options.ClientId = appSettings.Authentication.ClientId;
            options.CallbackPath = appSettings.Authentication.CallbackPath;
        });

    services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, options =>
    {
        options.UseTokenLifetime = false;
        options.Authority = options.Authority + "/v2.0/"; //Microsoft identity platform       
        options.TokenValidationParameters.ValidateIssuer = true;
        // https://stackoverflow.com/questions/49469979/azure-ad-b2c-user-identity-name-is-null-but-user-identity-m-instance-claims9
        // https://stackoverflow.com/questions/54444747/user-identity-name-is-null-after-federated-azure-ad-login-with-aspnetcore-2-2
        options.TokenValidationParameters.NameClaimType = "name";
        //https://stackoverflow.com/a/53918948/12300287
        options.Events.OnSignedOutCallbackRedirect = context =>
        {
            context.Response.Redirect("/UserAccess/LogoutSuccess");
            context.HandleResponse();

            return Task.CompletedTask;
        };
    });

    services.Configure<CookieAuthenticationOptions>(AzureADDefaults.CookieScheme, options =>
    {
        options.AccessDeniedPath = "/UserAccess/NotAuthorized";
        options.LogoutPath = "/UserAccess/Logout";
        options.ExpireTimeSpan = TimeSpan.FromMinutes(appSettings.Authentication.TimeoutInMinutes);
        options.SlidingExpiration = true;
    });
}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseExceptionHandler("/Home/Error");
        // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
        app.UseHsts();
    }

    app.UseHttpsRedirection();

    app.UseStaticFiles();

    app.UseRouting();
        
    app.UseAuthentication(); // who are you?            
    app.UseAuthorization(); // are you allowed?

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllerRoute(
            name: "default",
            pattern: "{controller=UserAccess}/{action=Login}/{id?}");
    });
}
Lukas

I hoped to find a way to create an AuthorizeAttribute filter to solve this issue, but due to time constraints I settled on a regular action filter. It works with AJAX calls and it redirects the user to the appropriate pages if they are unauthorized or unauthenticated:

AjaxAuthorize action filter:

//custom AjaxAuthorize filter inherits from ActionFilterAttribute because there is an issue with 
//a inheriting from AuthorizeAttribute.
//post about issue: 
//https://stackoverflow.com/questions/64017688/custom-authorization-filter-not-working-in-asp-net-core-3

//The statuses for AJAX calls are handled in InitializeGlobalAjaxEventHandlers JS function.

//While this filter was made to be used on actions that are called by AJAX, it can also handle
//authorization not called through AJAX.
//When using this filter always place it above any others as it is not guaranteed to run first.

//usage: [AjaxAuthorize(new[] {"RoleName", "AnotherRoleName"})]
public class AjaxAuthorize : ActionFilterAttribute
{
    public string[] Roles { get; set; }

    public AjaxAuthorize(params string[] roles)
    {
        Roles = roles;
    }

    public override void OnActionExecuting(ActionExecutingContext context)
    {
        string signInPageUrl = "/UserAccess/SignIn";
        string notAuthorizedUrl = "/UserAccess/NotAuthorized";

        if (context.HttpContext.User.Identity.IsAuthenticated)
        {
            if (Roles.Length > 0)
            {
                bool userHasRole = false;
                foreach (var item in Roles)
                {
                    if (context.HttpContext.User.IsInRole(item))
                    {
                        userHasRole = true;
                    }
                }
                if (userHasRole == false)
                {
                    if (context.HttpContext.Request.IsAjaxRequest())
                    {
                        context.HttpContext.Response.StatusCode = 401;
                        JsonResult jsonResult = new JsonResult(new { redirectUrl = notAuthorizedUrl });
                        context.Result = jsonResult;
                    }

                    else
                    {
                        context.Result = new RedirectResult(notAuthorizedUrl);
                    }
                }
            }

        }
        else
        {
            if (context.HttpContext.Request.IsAjaxRequest())
            {
                context.HttpContext.Response.StatusCode = 403;
                JsonResult jsonResult = new JsonResult(new { redirectUrl = signInPageUrl });
                context.Result = jsonResult;
            }
            else
            {
                context.Result = new RedirectResult(signInPageUrl);
            }
        }
    }
}

The IsAjaxRequest() extension used (reposted for a complete answer):

//Needed code equivalent of Request.IsAjaxRequest().
//Found this solution for ASP.NET Core: https://stackoverflow.com/questions/29282190/where-is-request-isajaxrequest-in-asp-net-core-mvc
//This is the one used in ASP.NET MVC 5: https://github.com/aspnet/AspNetWebStack/blob/master/src/System.Web.Mvc/AjaxRequestExtensions.cs
public static class AjaxRequestExtensions
{
    public static bool IsAjaxRequest(this HttpRequest request)
    {
        if (request == null)
        {
            throw new ArgumentNullException("request");
        }

        if (request.Headers != null)
        {
            return (request.Headers["X-Requested-With"] == "XMLHttpRequest");
        }

        return false;
    }
}

JavaScript ajax global error handler:

//global settings for the AJAX error handler. All AJAX error events are routed to this function.
function InitializeGlobalAjaxEventHandlers() {
    $(document).ajaxError(function (event, xhr, ajaxSettings, thrownError) {
        //these statuses are set in the [AjaxAuthorize] action filter
        if (xhr.status == 401 || xhr.status == 403) {
            var response = $.parseJSON(xhr.responseText);
            window.location.replace(response.redirectUrl);
        } else {
           RedirectUserToErrorPage();
        }     
    });
}

Collected from the Internet

Please contact [email protected] to delete if infringement.

edited at
0

Comments

0 comments
Login to comment

Related

Custom Authorization Filter in .NET Core API

Asp Net Core 3.1 Authorization by Custom Roles

Custom Authorization attribute asp.net core

custom authorization in asp.net core mvc

Custom AuthenticationHandler not working in Asp.Net Core 3

Custom Authorization attribute doesn't allow authorize in asp.net core 3

Relation between Authorization middleware and filter Asp.net core

Policy based Authorization not working in asp.net core

JWT bearer token Authorization not working asp net core web api

ASP.NET Core authorization not working for nested roles

Authorization is not working properly in ASP.NET Core MVC

ASP.NET Core Custom Authorization & Memory Cache

Custom authorization in ASP.NET Core with enum roles

Asp.Net Core custom authorization always ends with 401 Unauthorized

Authorization failing for custom authentication handler for ASP.NET Core 3.1?

ASP.NET Core Custom Policy Based Authorization - unclear

Custom Bearer Token Authorization for ASP.Net Core

Custom Authorization in Asp.net core 2.1 web api

Authorization Role/Policy Attributes Not Working In .Net Core 3

.Net Core 3 Custom Authorization Policy - Access to Action Verb

ASP.NET mvc 3 Anonymous Authorization not working

Override authorization policy in ASP.NET Core 3

ASP.NET Core Custom Role Based Authorization (Custom User.IsInRole)?

How to test custom action filter in ASP.NET Core?

ASP .NET Core Inject Service in custom page filter

Asp.net Core custom filter implementing IActionModelConvention and IFilterFactory

ASP.NET Core - custom AspNetCore.Identity implementation not working

ASP.Net Core Web API custom route not working

Asp.Net Core 3.0 Custom Control Validation is not working