Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Setting global authorization policies using the DefaultPolicy and the FallbackPolicy in ASP.NET Core 3.x

$
0
0

ASP.NET Core has an extensive authorization system that you can use to create complex authorization policies. In this post, I look at the various ways you can apply these policies to large sections of your application.

I wrote about creating custom authorization policies several years ago. For a more up-to-date look, my new book ASP.NET Core in Action is currently in pre-release. Use code mllock2 to get 50% off until June 10th 2020!

We'll start by configuring a global AuthorizeFilter and see why that's no longer the recommended approach in ASP.NET Core 3.0+. We'll then look at the alternative, using endpoint routing, as well as using Razor Page conventions to apply different authorization policies to different parts of your app. We'll also compare the DefaultPolicy to the FallbackPolicy see when each of them are applied, and how you can update them.

For the purposes of this post, I'll assume you have a standard Razor Pages application, with a Startup.cs something like the following. The details of this aren't very important, I just assume you have already configured authentication and a UI system for your application.

public class Startup
{
    public IConfiguration Configuration { get; }
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public void ConfigureServices(IServiceCollection services)
    {
        // Configure ASP.NET Core Identity + EF Core
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))
        );

        services.AddDefaultIdentity<IdentityUser>()
            .AddEntityFrameworkStores<AppDbContext>();

        // Add Razor Pages services
        services.AddRazorPages();

        // Add base authorization services
        services.AddAuthorization();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseStaticFiles();

        // Ensure the following middleware are in the order shown
        app.UseRouting();

        app.UseAuthentication();
        app.UseAuthorization();

        app.UseEndpoints(endpoints =>
        {
            // Add Razor Pages to the application
            endpoints.MapRazorPages();
        });
    }
}

At this point, you have authentication, and you want to start protecting your application. You could apply [Authorize] attributes to every Razor Page, but you want to be a bit safer, and apply authorization globally, to all the pages in your application. For the rest of this post, we look at the various options available.

Globally applying an AuthorizeFilter

The first option is to apply an AuthorizeFilter globally to all your MVC actions and Razor Pages. This is the approach traditionally used in earlier versions of ASP.NET Core.

Note that this is not the recommend approach to apply authorization globally in ASP.NET Core 3.0+. You'll see other approaches later in the post.

For example, you can add an AuthorizeFilter to all your Razor Page actions when configuring your Razor Pages in ConfigureServices (you can configure MVC controllers in a similar way):

public void ConfigureServices(IServiceCollection services)
{
    // ...other config as before

    // Add a default AuthorizeFilter to all endpoints
    services.AddRazorPages()
        .AddMvcOptions(options => options.Filters.Add(new AuthorizeFilter()));
}

This is equivalent to decorating all your Razor Pages with an [Authorize] attribute, so users are authorized using the DefaultPolicy (more on that shortly!), which by default just requires an authenticated user. If you're not authenticated, you'll be redirected to the login page for Razor Pages apps (you'll receive a 401 response for APIs).

If you want to apply a different policy, you can specify one in the constructor of the AuthorizeFilter:

public void ConfigureServices(IServiceCollection services)
{
    // ...other config as before

    // Pass a policy in the constructor of the Authorization filter
    services.AddRazorPages()
        .AddMvcOptions(options => options.Filters.Add(new AuthorizeFilter("MyCustomPolicy")));

    // Configure the custom policy
    services.AddAuthorization(options =>
    {
        options.AddPolicy("MyCustomPolicy",
            policyBuilder => policyBuilder.RequireClaim("SomeClaim"));
    });
}

The authorization filter is still applied globally, so users will always be required to login, but now they must also satisfy the "MyCustomPolicy" policy. If they don't, they'll be redirected to an access denied page for Razor Pages apps (or receive a 403 for APIs).

Remember, this policy applies globally so you need to ensure your "Login" and "AccessDenied" pages are decorated with [AllowAnonymous], otherwise you'll end up with endless redirects.

Applying AuthorizeFilters like this was the standard approach for early versions of ASP.NET Core, but ASP.NET Core 3.0 introduced endpoint routing. Endpoint routing allows moving some previously-MVC-only features to being first-class citizens. Authorization is one of those features!

Using RequireAuthorization on endpoint definitions

The big problem with the AuthorizeFilter approach is that it's an MVC-only feature. ASP.NET Core 3.0+ provides a different mechanism for setting authorization on endpoints—the RequireAuthorization() extension method on IEndpointConventionBuilder.

Instead of configuring a global AuthorizeFilter, call RequireAuthorization() when configuring the endpoints of your application, in Configure():

public void ConfigureServices(IServiceCollection services)
{
    // ...other config as before

    // No need to add extra filters
    services.AddRazorPages();

    // Default authorization services
    services.AddAuthorization();
}

public void Configure(IApplicationBuilder app)
{
    app.UseStaticFiles();

    app.UseRouting();

    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        // Require Authorization for all your Razor Pages
        endpoints.MapRazorPages().RequireAuthorization();
    });
}

The net effect of this is the same as applying a global AuthorizeFilter. So why use this approach? One big advantage is the ability to add authorization for other endpoints, that aren't MVC or Razor Pages. For example, you could require authenticated requests for your health check endpoints:

app.UseEndpoints(endpoints =>
{
    // Require Authorization for all your Razor Pages
    endpoints.MapRazorPages().RequireAuthorization();
    // Also require authorization for your health check endpoints
    endpoints.MapHealthChecks("/healthz").RequireAuthorization();
});

As before, you can specify a different policy to apply in the call to RequireAuthorization(). You could also provide different policies to apply for different endpoints. In the example below I'm applying the "MyCustomPolicy" policy to the Razor Pages endpoints, and two policies, "OtherPolicy" and "MainPolicy" to the health check endpoints:

app.UseEndpoints(endpoints =>
{
    // Require Authorization for all your Razor Pages
    endpoints.MapRazorPages().RequireAuthorization("MyCustomPolicy");
    // Also require authorization for your health check endpoints
    endpoints.MapHealthChecks("/healthz").RequireAuthorization("OtherPolicy", "MainPolicy");
});

As always, ensure you've registered the policies in the call to AddAuthorization(), and ensure your added [AllowAnonymous] to your Login Access Denied pages.

If you don't provide a policy name in the RequireAuthorization() call, then the DefaultPolicy is applied. This is the same behaviour as using an [Authorize] filter without a policy name.

Changing the DefaultPolicy for an application

The DefaultPolicy is the policy that is applied when:

  • You specify that authorization is required, either using RequireAuthorization(), by applying an AuthorizeFilter, or by using the [Authorize] attribute on your actions/Razor Pages.
  • You don't specify which policy to use.

Out-of-the-box, the DefaultPolicy is configured as the following:

new AuthorizationPolicyBuilder()
    .RequireAuthenticatedUser()
    .Build();

That means if you're authenticated, then you're authorized. This provides the default behaviour that you're likely familiar with, of redirecting unauthenticated users to the login page, but allowing any authenticated user access to the page.

You can change the DefaultPolicy so that an empty [Authorize] attribute applies a different policy in UseAuthorization(). For example, the following sets the DefaultPolicy to a policy that requires users have the Claim "SomeClaim".

public void ConfigureServices(IServiceCollection services)
{
    // ...other config as before
    services.AddRazorPages();

    services.AddAuthorization(options =>
    {
        // Configure the default policy
        options.DefaultPolicy = new AuthorizationPolicyBuilder()
            .RequireClaim("SomeClaim")
            .Build();

        // ...other policy configuration
    });
}

public void Configure(IApplicationBuilder app)
{
    app.UseStaticFiles();

    app.UseRouting();

    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        // The default policy applies here, as no other policy set
        endpoints.MapRazorPages().RequireAuthorization();
        
        // DefaultPolicy not used, as OtherPolicy is provided
        endpoints.MapHealthChecks("/healthz").RequireAuthorization("OtherPolicy");

        // DefaultPolicy not applied, as authorization not required
        endpoints.MapHealthChecks("/ready");
    });
}

The example above shows when the DefaultPolicy is applied, and when it isn't. The DefaultPolicy only applies when you've request authorization and you haven't specified a different policy. So it only applies to the Razor Pages endpoints in the example above.

Applying a default policy like this can be very useful, but sometimes you want to have slightly more granular control over when to apply policies. In Razor Pages applications for example, you might want to apply a given policy to one folder, and a different policy to another folder or area. You can achieve that using Razor Pages conventions.

Applying authorization policies using conventions with Razor Pages

The Razor Pages framework is designed around a whole set of conventions that are designed to make it easy to quickly build applications. However, you can customise virtually all of those conventions when your app starts, and authorization is no different.

The Razor Page conventions allow you to set authorization requirements based on a folder, area, or page. They also allow you to mark sections and pages with AllowAnonymous in situations where you need to "punch a hole" through the default authorization policy. The documentation on this feature is excellent, so I've just provided a brief example below:

public void ConfigureServices(IServiceCollection services)
{
    // ...other config as before
    
    // Applying multiple conventions 
    services.AddRazorPages(options => 
    {
        // These apply authorization policies to various folders and pages
        options.Conventions.AuthorizeAreaFolder("Users", "/Accounts");
        options.Conventions.AuthorizePage("/ChangePassword");

        // You can provide the policy as an optional parameter, otherwise the DefaultPolicy is used
        options.Conventions.AuthorizeFolder("/Management", "MyCustomPolicy"); 

        // You can also configure [AllowAnonymous] for pages/folders/areas
        options.Conventions.AllowAnonymousToAreaPage("Identity", "/Account/AccessDenied");
    });

    services.AddAuthorization(options =>
    {
        // ...other policy configuration
    });
}

These conventions can be useful for broadly applying authorization policies to whole sections of your application. But what if you just want to apply authorization everywhere? That's where the FallbackPolicy comes in.

Using the FallbackPolicy to authorize everything

The FallbackPolicy is applied when the following is true:

  • The endpoint does not have any authorisation applied. No [Authorize] attribute, no RequireAuthorization, nothing.
  • The endpoint does not have an [AllowAnonymous] applied, either explicitly or using conventions.

So the FallbackPolicy only applies if you don't apply any other sort of authorization policy, including the DefaultPolicy, When that's true, the FallbackPolicy is used.

By default, the FallbackPolicy is a no-op; it allows all requests without authorization. You can change the FallbackPolicy in the same way as the DefaultPolicy, in UseAuthorization:

public void ConfigureServices(IServiceCollection services)
{
    // ...other config as before
    services.AddRazorPages();

    services.AddAuthorization(options =>
    {
        // Configure the default policy
        options.FallbackPolicy = new AuthorizationPolicyBuilder()
            .RequireClaim("SomeClaim")
            .Build();

        // ...other policy configuration
    });
}

public void Configure(IApplicationBuilder app)
{
    app.UseStaticFiles();

    app.UseRouting();

    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        // The FallbackPolicy applies here, as no other policy set
        endpoints.MapRazorPages()
        
        // FallbackPolicy not used, as authorization applied
        endpoints.MapHealthChecks("/healthz").RequireAuthorization("OtherPolicy");

        // FallbackPolicy not used, as DefaultPolicy applied
        endpoints.MapHealthChecks("/ready").RequireAuthorization();
    });
}

In the example above, the FallbackPolicy is set to a custom policy. It only applies to the Razor Pages endpoints, as the health check endpoints have specified authorization requirements of "OtherPolicy" and the DefaultPolicy.

With the combination of the DefaultPolicy, FallbackPolicy, Razor Page conventions, and the RequireAuthorization() extension method, you have multiple ways to apply authorization "globally" in your application. Remember that you can always override the DefaultPolicy and FallbackPolicy to achieve a more specific behaviour by applying an [Authorize] or [AllowAnonymous] attribute directly to a Razor Page or action method.

Summary

In this post I described the various options available for setting global authorization policies:

  • Apply an AuthorizeFilter globally. This is no longer the recommended approach, as it is limited to MVC/Razor Pages endpoints.
  • Call RequireAuthorization() when configuring an endpoint in UseEndpoints(). You can specify a policy to apply, or leave blank to apply the DefaultPolicy.
  • Apply authorization to Razor Pages using conventions when you call AddRazorPages(). You can apply authorization policies and [AllowAnonymous] using these conventions.
  • The DefaultPolicy is applied when you specify that authorization is required, but don't specify a policy to apply. By default, the DefaultPolicy authorizes all authenticated users.
  • The FallbackPolicy is applied when no authorization requirements are specified, including the [Authorize] attribute or equivalent. By default, the FallbackPolicy does not apply any authorization.

Viewing all articles
Browse latest Browse all 743

Trending Articles