[C#] Authorisation basics and custom authorisation policies
- siqisundev
- Nov 23, 2022
- 5 min read
In this post we discussed the .NET Authentication schemes and implemented our custom authentication handler. And here we will be diving into the Authorisation component and, likewise, implement our own authentication rules to enhance our understanding.
Components
Policy
.NET authorisation works on policies. A policy is composed of a set of requirements (based on user identity) and can be assigned on the Global, Controller or Action (or endpoint, hereinafter referred as just "Action").
Applying Policies
Policies can be applied on Controller and Action level using [Authorize] attribute (form of which to be discussed below). If a policy is expected to be applied globally, we can specify the global policy in the code where we configure the pipelines.
app.MapControllers().RequireAuthorization("TheGlobalPolicy");
//Apply the policies to all the endpoints added by "AddController"
Multiple policies can be applied on the same controller/action(endpoint). Policies from different levels are merged, and one need to satisfy ALL requirements from the merged policy to be successfully authorised.
In the process of authorisation, the requirement of the policies are assessed in the order of "global->controller->actions" and a fail fast strategy is applied. This resulted in controller's authorisation cannot be "stricter" than the actions' authorisation policy (in that controller's policy can be a superset of actions' policy but not vice versa, for example, controller can have a policy requesting the "Admin" OR "Manager" role and an action can have a policy allowing only "Admin" role etc.). This default short circuiting behaviour can be reverted by setting the InvokeHandlerAfterFailure option to false in configuring the authorization.
builder
.Services
.AddAuthorization(
options =>
{
options.InvokeHandlersAfterFailure = false;
}
);
In the case where InvokeHandlersAfterFailure equals false, depending on how the authorisation handler assess a particular result, later assessed requirement might be able to revert failure results caused by not satisfying earlier results.
Authorisation can be skipped with [AllowAnonymous] attribute, according to the Microsoft document. AllowAnonymous attribute bypasses all policies on all levels.
Policy Types
Empty and Default Policy
Not necessarily a build in policy, if a controller or an Action is decorated with [Authorize] attribute without any configuration, it's using the specified default policy, if not specified, it's implicitly an empty policy with no requirements (essentially the authorisation is successful as long as it's an authenticated user).
[HttpPost]
[Authorize] //apply "Empty or default policy"
public IActionResult GetInfo(GetInfoRequest request){/*...*/}
//use default policy
//OR
//As long as the authentication is successful, it's authorized
To specify a default policy, add the default policy under the custom delegate in the AddAuthorization
builder
.Services
.AddAuthorization(
options =>
{
options.DefaultPolicy = new AuthorizationPolicyBuilder().Build();
}
);
Role based policy
Role based policy is a built in policy, the requirement of which requests the authenticated identity to have corresponding role claim. The default handler of the policies requirement check the claim type of "http://schemas.microsoft.com/ws/2008/06/identity/claims/role" [Under the ClaimType constant]
Roles based policies are applied by decorating the controller/actions with Authorize attribute with Role options
[Authorize(Roles="Manager,Director")] /*Alternative roles are splitted with comma*/
[Authorize(Roles="Admin")]
/*policies merged with AND policy, meaning the identity need to either has Manager AND Admin role or has Director and Admin role*/
public IActionResult GetInfo(...){/*...*/}
Claim based policy
A more generic form of Role based policy. Role based policy essentially checks for the "Role" claim whereas the claim based policy checks for any specified claims.
Claim based policy need to be registered when configuring the authorization as below.
builder
.Services
.AddAuthorization(
options =>
{
options.AddPolicy("EmployeeOnly",
policy => policy.RequireClaim("EmployeeNumber");
); //passed as long as there is an employee number
options.AddPolicy("SpecificEmployeeOnly",
policy => policy.RequireClaim("EmployeeNumber","1","2","3","4","5");
); //passed only if the claim employeenumber is 1,2,3,4 or 5
}
);
And use the policy with Authorize attribute specifying the policy name (name is specified in the "AddPolicy" call)
[Authorize(Policy="EmployeeOnly")] /
public IActionResult GetInfo(...){/*...*/}
Custom policy
The most generic form of a policy, requirements will need to be specified to the policy by calling AddRequirement method. The requirement object need to implement the IAuthorizationRequirement interface and is essentially a POCO indicating the configuration items to handle the requirement (e.g. the DateTime indicating the earliest date of birth of the identity). The handler will make use of the requirement object and process accordingly as part of authorization process. So a requirement object and the authorization handler handling the requirement are essentially what's needed for implementing a custom policy, we will be implementing our own custom policy in the next section.
Middleware and related services
So far we discussed different type of policies for authorization and understand the authorisation process is all about assessing the authorization requirements from different policies. A simplified description of the implementation of authorization in .NET is as follows:
When the authorization is configured, the authorization middleware will be available in the request processing pipeline to handle authorization*, the middleware will combine all policies applied on the endpoint being visited and have the policy evaluator to evaluate the policy. The evaluator relies on authorization service which will find all corresponding authorization handlers to assess the requirements (against the authenticated identity). Handlers handling the policy requirements is a process of changing the authorization context, whose final status post the handling processes will be assessed by the evaluator to determine the outcome being Success, Challenge or Forbid.
All relevant classes/services are being registered with AddAuthorization() builder method. The method is getting called in the AddControllers() builder method so you don't need to explicitly call the method if you want to leave all the configuration as default. You will obviously need to overwrite the configuration by calling AddAuthentication(Action<AuthOptions> configureOptions) if you want to add extra policies or change the default configuration of authorization.
Identity and Handler and Authorisation Context
The mentioned authenticated identity is a built in object indicating the requestor's identity and the claims corresponding to the identity. The identity object is initialised as a result of the successful authorization and is available in the AuthorizationHandlerContext.User object for the authorization handler to assess based on the requirements.
Success, Challenge or Forbid
The default process evaluator assess the authorization result based on the combination of authorization and authentication result:
| Authentication identity provided | Authentication identity not provided |
Authorization requirement satisfied | SUCCEED | CHALLENGE |
Authorization requirement not satisfied | FORBIDDEN | CHALLENGE |
ChallengeAsync and ForbidAsync are two extra overridable methods in the AuthorizationHandlers introduced in the post discussing Authentication. The Authorization middleware will respectively call the two methods on the circumstance where the authorization result is FORBIDDEN or CHALLENGE and it's down to the previously discussed authentication schema to determine which handler's implementation of ChallengeAsync and ForbidAsync is to be called.
Implementing custom policy and handler
Now that we understand how .NET handles authorisation, we will be implementing a simple authorisation policy that requires the user's claim "MagicWord" to contain some special strings.
As discussed, the authorization middleware will need a requirement object and the handler for the requirement to evaluate the policy. We start by defining the requirement by declaring a requirement class.
public class MagicWordRequirement : IAuthorizationRequirement
{
public MagicWordRequirement(string magicWord) => MagicWord = magicWord;
public string MagicWord { get; set; }
}
The handler of the requirement should implement a closed generic base class of AuthorizationHandler<TRequirement> so we need to define the class and override handle method.
public class MagicWordAuthorizationHandler : AuthorizationHandler<MagicWordRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MagicWordRequirement requirement)
{
var magicWord = context.User.Claims.FirstOrDefault(x => x.Type == "MagicWord")?.Value;
if (
!string.IsNullOrEmpty(magicWord)
&&
requirement.MagicWord!.Contains(
magicWord,
StringComparison.CurrentCultureIgnoreCase
)
)
{
context.Succeed(requirement);
return Task.CompletedTask;
}
context.Fail(new AuthorizationFailureReason(this, "Magic word not found"));
return Task.CompletedTask;
}
}
The handler need to be registered in the DI container so that the authorization service can find the handler for the policy's requirement:
builder.Services.AddScoped<IAuthorizationHandler, MagicWordAuthorizationHandler>();
Policies can then be created based on the concrete object of the requirement, either initialised from hard coding (not a good practice, obviously) or mapped from configurations.
builder
.Services
.AddAuthorization(
options =>
{
options.AddPolicy("VerifyMagicWord", policy =>
{
policy.Requirements.Add(new MagicWordRequirement(builder.Configuration["MagicKeyword"]?.ToString() ?? "DefaultMagicWord"));
});
options.AddPolicy("VerifyAnotherMagicWord", policy =>
{
policy.Requirements.Add(new MagicWordRequirement("HardCodedMagicWord"));
});
}
);
Comments