I'm creating a simple Asp.Net Core Web App using Razor Pages and Asp.Net Core Identity and I having an issue where the authorization handlers for policies applied to a PageModel are still executed after a policy applied for the same Page's folder despite each authorization handler is related to a different Requirement Class. My problem is that even calling Context.Fail() at the folder policy authorization handler, an unauthenticated user seems to still reach the Page and this fires the Page policy authorization handler then throws an exception because the User object is null. I want to implement a simple Flow where requests to Pages are first validated by the policies set on the Folders they belong to, ok? continue to Page (and run the Page authorization handler), not ok, return a forbidden response immediately.
Any help is really appreciated.
Requirements:
public class SectionsAccessRequirement : IAuthorizationRequirement
{
public string SectionName { get; }
public SectionsAccessRequirement(string Name)
{
SectionName = Name;
}
}
public class RecordCreateRequirement : IAuthorizationRequirement { }
Authorization handlers:
public class SectionsAccessHandler : AuthorizationHandler<SectionsAccessRequirement>
{
public IHttpContextAccessor _accessor;
public SectionsAccessHandler(IHttpContextAccessor Accessor) => _accessor = Accessor;
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SectionsAccessRequirement requirement)
{
// Require authentication
if (!_accessor.HttpContext.User.Identity.IsAuthenticated) context.Fail();
// Admin
if (context.User.HasClaim(c => c.Type == ClaimNameConstants.UserGroup && c.Value == UserGroupConstants.AdminGroup)) context.Succeed(requirement);
if (requirement.SectionName == SectionNameConstants.AdminSection) context.Fail();
// Client
if (requirement.SectionName == SectionNameConstants.ClientSection && context.User.HasClaim(c => c.Type == ClaimNameConstants.ClientSection)) context.Succeed(requirement);
return Task.CompletedTask;
}
}
public class RecordCreateAuthorizationHandler : AuthorizationHandler<RecordCreateRequirement>
{
private readonly UserManager<AppUser> _users;
public RecordCreateAuthorizationHandler(UserManager<AppUser> Users) => _users = Users;
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RecordCreateRequirement requirement)
{
// User ID
int sessionUserId = int.Parse(_users.GetUserId(context.User));
// Privileges
if (!context.User.HasClaim(c => c.Type == ClaimNameConstants.UserGroup && c.Value == UserGroupConstants.AdminGroup))
{
if (context.User.HasClaim(c => c.Type == ClaimNameConstants.ClientCreate)) context.Succeed(requirement);
}
else
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
I have these two extension methods:
public static class AuthorizationOptionsExtension
{
public static void AddPolicies(this AuthorizationOptions Options)
{
// Folders policies
Options.AddPolicy(PolicyNameConstants.AdminSectionPolicy, p => p.Requirements.Add(new SectionsAccessRequirement(SectionNameConstants.AdminSection)));
Options.AddPolicy(PolicyNameConstants.ClientSectionPolicy, p => p.Requirements.Add(new SectionsAccessRequirement(SectionNameConstants.ClientSection)));
// Pages policies
Options.AddPolicy(PolicyNameConstants.ClientRecordCreatePolicy, p => p.Requirements.Add(new Client.RecordCreateRequirement()));
// Fallback policy
Options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
}
}
And:
public static class RazorPagesOptionsExtension
{
public static void AddFoldersAuthorization(this RazorPagesOptions Options)
{
Options.Conventions.AuthorizeFolder("/Admin", PolicyNameConstants.AdminSectionPolicy);
Options.Conventions.AuthorizeFolder("/Client", PolicyNameConstants.ClientSectionPolicy);
}
}
The Page's policy is applied using an Authorize attribute:
[Authorize(Policy = PolicyNameConstants.ClientRecordCreatePolicy)]
public class CreateModel : PageModel
{ ... }
This is my startup class, I'm using Asp.Net Core Identity:
public class Startup
{
private readonly IConfiguration _config;
public Startup(IConfiguration config)
{
_config = config;
}
// Add (and configure) services to the container
public void ConfigureServices(IServiceCollection services)
{
// Global application settings (formerly known as Web.config) and other settings
services.Configure<ApplicationSettings>(_config);
// Application
services.AddApplicationLayerServices();
services.AddRazorPages();
services.AddHttpContextAccessor();
services.Configure<RazorViewEngineOptions>(options =>
{
options.PageViewLocationFormats.Add("/Pages/Shared/Partials/{0}" + RazorViewEngine.ViewExtension);
options.PageViewLocationFormats.Add("/Pages/Client/Partials/{0}" + RazorViewEngine.ViewExtension);
});
// Data store
services.AddDbContext<DataContext>(options => options.UseOracle(_config.GetConnectionString("AppConnectionString")));
// Security
services.AddIdentity<AppUser, IdentityRole<int>>(options => {
options.Password.RequireUppercase = false;
options.Password.RequiredLength = 8;
}).AddEntityFrameworkStores<DataContext>();
services.ConfigureApplicationCookie(options =>
{
// Cookie settings
options.Cookie.HttpOnly = true;
options.ExpireTimeSpan = TimeSpan.FromMinutes(15);
options.LoginPath = "/SignIn";
options.AccessDeniedPath = "/FourZeroSomething";
options.SlidingExpiration = true;
});
services.AddAccessControlLayerServices();
services.AddAuthorization(options => options.AddPolicies());
services.Configure<RazorPagesOptions>(options => options.AddFoldersAuthorization());
}
// Configure the HTTP request pipeline
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
}
// Navigation
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// Security
app.UseAuthentication();
app.UseAuthorization();
// Endpoints
app.UseEndpoints(endpoints => endpoints.MapRazorPages());
}
}
}
As detailed in the docs, this behaviour is by design:
If a handler calls
context.Succeed
orcontext.Fail
, all other handlers are still called. This allows requirements to produce side effects, such as logging, which takes place even if another handler has successfully validated or failed a requirement. When set tofalse
, theInvokeHandlersAfterFailure
property short-circuits the execution of handlers whencontext.Fail
is called.InvokeHandlersAfterFailure
defaults totrue
, in which case all handlers are called.
If you'd rather not set the InvokeHandlersAfterFailure
option, you can check the AuthorizationHandlerContext.HasFailed
property in your more specific handlers to see if it's false
and return early. e.g.:
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RecordCreateRequirement requirement)
{
if (context.HasFailed)
return;
// User ID
int sessionUserId = int.Parse(_users.GetUserId(context.User));
// ...
}