Question: Using ASP.NET Core 6 (MVC, Razor), I am struggling to correctly and efficiently implement Authorization when it comes to a user invoking certain Actions/Controllers - with CookieAuthentication.
To phrase it with more detail: I am trying to tell the .NET Core request pipeline how to distinguish between invocations on Actions/Controllers from:
The tricky bit is - these users must all be routed to a different page depending on what is lacking
Let's assume all users concerned are authenticated (because I've got that working just fine. Unauthenticated users are supposed to be redirected to the login screen).
Next, I've used Claims (to populate a Claims Identity, which populates the ClaimsPrincipal) during the HttpContext.SignInAsync() process to determine the user's role (e.g. manager or user etc.), and decorated the appropriate Controllers/Actions with (and in combination)
[Authorize(Roles = "User")] //A user must have "user level" privileges
[Authorize(Policy = "BasicEdition")] //User must have the "basicEdition" subscription tier to access this method
Problem is I cannot get the request pipeline to redirect the user to different paths/URLs/actions depending on which one of these Authorize attributes fail.
For instance: If you're an authenticated user, and do not have "manager-level" privileges, you get redirected to accounts/ask-your-manager. However, if you're an authenticated user, and do not have "AdvancedEdition" subscription, you get redirected to subscriptions/upgrade-to-access.
I really hope I could explain this simply - Now for my code:
Startup.cs file:
services.AddAuthentication(opts =>
{ //I have even tried not using this.
opts.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
opts.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
opts.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(c =>
{
c.Cookie.Name = "AuthCookie";
c.LoginPath = "/accounts/login";
c.Cookie.IsEssential = true;
c.AccessDeniedPath = "/accounts/accessdenied";
c.SlidingExpiration = true;
c.ExpireTimeSpan = new TimeSpan(0, 2, 0);
});
services.AddAuthorization(config =>
{
//This should be the Tiers/Editions
config.AddPolicy("BasicEdition", policyBuilder =>
{
policyBuilder.UserRequireCustomClaim("BasicEdition");
});
config.AddPolicy("AdvancedEdition", policyBuilder =>
{
policyBuilder.UserRequireCustomClaim("AdvancedEdition");
});
config.AddPolicy("PremiumEdition", policyBuilder =>
{
policyBuilder.UserRequireCustomClaim("PremiumEdition");
});
});
services.AddScoped<IAuthorizationHandler, PoliciesAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, RolesAuthorizationHandler>();
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromHours(12);
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
});
services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => context.Request.PathBase.Equals("/NeedsConsent");
});
Then the Configure() method in the same class:
app.UseCookiePolicy();
app.UseAuthentication();
app.UseRouting();
app.UseAuthorization();
app.UseSession();
And then simply placing the [Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
attribute on the Controllers in general. That works, and will send my user directly to the login screen - this is correct and as expected.
Currently, I use this these bits to hook into the IAuthorizationHandler (according to this) to check the Role, and similarly, the Policy to interpret the subscription. However, I have discovered that unlike previous versions of the context, I cannot assign a new RedirectToAction (or anything like it) to it. I can merely tell the context whether or not the the request has succeeded or failed. How can I change this? Do I need to implement Filters or different types?
public class PoliciesAuthorizationHandler : AuthorizationHandler<CustomUserRequireClaim>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
CustomUserRequireClaim requirement)
{
if (context.User == null || !context.User.Identity.IsAuthenticated)
{
context.Fail();
return Task.CompletedTask;
}
var hasClaim = context.User.Claims.Any(c => c.Value == requirement.ClaimType);
if (hasClaim)
{
context.Succeed(requirement);
return Task.CompletedTask;
}
context.Fail();
return Task.CompletedTask;
}
}
public class CustomUserRequireClaim : IAuthorizationRequirement
{
public string ClaimType { get; }
public CustomUserRequireClaim(string claimType)
{
ClaimType = claimType;
}
}
public class RolesAuthorizationHandler : AuthorizationHandler<RolesAuthorizationRequirement>, IAuthorizationHandler
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
RolesAuthorizationRequirement requirement)
{
if (context.User == null || !context.User.Identity.IsAuthenticated)
{
context.Fail();
return Task.CompletedTask;
}
var validRole = false;
if (requirement.AllowedRoles == null ||
requirement.AllowedRoles.Any() == false)
{
validRole = true;
}
else
{
var claims = context.User.Claims;
//var userName = claims.FirstOrDefault(c => c.Type == "UserId").Value;
var roles = requirement.AllowedRoles;
validRole = context.User.Claims.Any(c => c.Type == ClaimsHelper.Claim_UserRole
&& roles.Contains(c.Value));
//validRole = new Users().GetUsers().Where(p => roles.Contains(p.Role) && p.UserName == userName).Any();
}
if (validRole)
{
context.Succeed(requirement);
}
else
{
context.Fail();
}
return Task.CompletedTask;
}
}
I have followed MANY different articles on S.O as well as other forums to try and achieve my goal. Including this wonderful article that sent me down this road. These articles (and credit to the respective OPs and those that answered them) helped me point in the right direction, however it is not exactly what I need.
How do you create a custom AuthorizeAttribute in ASP.NET Core?
(Yes I know this is a dated version of .NET Core) How to add multiple policies in action using Authorize attribute using identity 2.0?
I have even considered combining them (kinda like multiplexing) the policies/roles but that doesn't do the exact thing I need either - since they will both send me down the same page. How to include multiple policies
Your assistance, or even just pointing me in the right direction is truly appreciated. I'd like to gain a good understanding of what I'm building here to ensure I can optimize it later, and even expand on it.
UPDATE: In conjunction with the accepted answer, what I also discovered was that my startup.cs file configuration was incorrect to allow the solution to work. Inside the Startup.Configure() method, I used:
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
Instead of:
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
You should create your own custom IAuthorizationMiddlewareResultHandler
. Take a look at the documentation here: https://learn.microsoft.com/en-us/aspnet/core/security/authorization/customizingauthorizationmiddlewareresponse?view=aspnetcore-6.0
Example for your scenario:
public class SampleAuthorizationMiddlewareResultHandler : IAuthorizationMiddlewareResultHandler
{
private readonly AuthorizationMiddlewareResultHandler defaultHandler = new();
public async Task HandleAsync(
RequestDelegate next,
HttpContext context,
AuthorizationPolicy policy,
PolicyAuthorizationResult authorizeResult)
{
if (authorizeResult.Forbidden)
{
if (authorizeResult.AuthorizationFailure!.FailedRequirements
.OfType<CustomUserRequireClaim>().Any())
{
context.Response.Redirect("/subscriptions/upgrade-to-access");
return;
}
if (authorizeResult.AuthorizationFailure!.FailedRequirements
.OfType<RolesAuthorizationRequirement>().Any())
{
context.Response.Redirect("/accounts/ask-your-manager");
return;
}
}
await defaultHandler.HandleAsync(next, context, policy, authorizeResult);
}
}
Program.cs:
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, SampleAuthorizationMiddlewareResultHandler>();
The handler is triggered automatically by the AuthorizationMiddleware
(app.UseAuthorization()
) for every controller/action. The middleware evaluates the authorization policies you have defined for the controller/action (e.g.: [Authorize(Policy = "BasicEdition")]
or [Authorize(Roles = "User")]
) then passes the authorization result to this handler. Inside the handler we can customize the response based on the authorization policy and the authorization result.