Note: This article belongs to the "Understanding ASP.NET Core" series. Please check the pinned blog or click here to view the full table of contents.
Previously, we have learned about authentication in ASP.NET Core. Now, let's discuss authorization.
As usual, the sample program source code XXTk.Auth.Samples has been submitted. Feel free to grab it if needed.
1. Overview
There are many ways to implement authorization in ASP.NET Core. Let's explore three common approaches:
- Role-based authorization
- Claims-based authorization
- Policy-based authorization
Among them, policy-based authorization is the focus we need to understand.
Before diving into the main content, let's first get acquainted with an important attribute—AuthorizeAttribute. Through it, we can conveniently apply permission control at Controller, Action, and other dimensions:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public class AuthorizeAttribute : Attribute, IAuthorizeData
{
public AuthorizeAttribute() { }
public AuthorizeAttribute(string policy)
{
Policy = policy;
}
// Policy
public string? Policy { get; set; }
// Roles, multiple roles can be separated by commas to form a list
public string? Roles { get; set; }
// Authentication schemes, multiple schemes can be separated by commas to form a list
public string? AuthenticationSchemes { get; set; }
}
Additionally, for testing convenience, let's first add cookie-based authentication:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "auth";
// Return 401 when user not logged in
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
// Return 403 when user lacks access
options.Events.OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
};
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
In Configure, the authorization middleware AuthorizationMiddleware is added to the request pipeline via app.UseAuthorization().
2. Role-based Authorization
Sample program: XXTk.Auth.Samples.RoleBased.HttpApi
As the name suggests, role-based authorization checks whether the user has a specified role. If yes, authorization is granted; otherwise, it is denied.
Let's look at a simple example:
[Authorize(Roles = "Admin")]
public string GetForAdmin()
{
return "Admin only";
}
Here, we set the Roles property of the AuthorizeAttribute to Admin, meaning that if a user wants to access the GetForAdmin endpoint, they must have the Admin role.
What if an endpoint should allow multiple roles? Simple: separate the roles with commas (,):
[Authorize(Roles = "Developer,Tester")]
public string GetForDeveloperOrTester()
{
return "Developer || Tester";
}
As shown above, separating Developer and Tester with a comma allows access if the user has either role.
Finally, if an endpoint requires a user to have multiple roles simultaneously, you can add multiple AuthorizeAttribute attributes:
[Authorize(Roles = "Developer")]
[Authorize(Roles = "Tester")]
public string GetForDeveloperAndTester()
{
return "Developer && Tester";
}
The user is allowed to access the endpoint only if they have both Developer and Tester roles.
You're probably eager to test this yourself, but do you recall how to set user roles? As introduced in the authentication article, roles can be added via claims when issuing the identity ticket. For example:
public async Task<IActionResult> LoginForAdmin()
{
var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
identity.AddClaims(new[]
{
new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString("N")),
new Claim(ClaimTypes.Name, "AdminOnly"),
// Add role Admin
new Claim(ClaimTypes.Role, "Admin")
});
var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
return Ok();
}
Due to space constraints, other login code is omitted; it can be found in the sample program.
3. Claims-based Authorization
Sample program: XXTk.Auth.Samples.ClaimsBased.HttpApi
Role-based authorization, introduced above, is essentially based on the "role" claim. Claims-based authorization extends this to all claims (not just roles).
Claims-based authorization is built on top of policy-based authorization. Why? Because we need to add a policy to use claims:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthorization(options =>
{
// ... Policies can be added here
});
}
}
A simple claims policy looks like this:
options.AddPolicy("RankClaim", policy => policy.RequireClaim("Rank"));
This policy is named RankClaim and requires the user to have a claim of type Rank. The specific value of Rank is not important; just having the claim is sufficient.
Of course, we can also restrict the value of Rank:
options.AddPolicy("RankClaimP3", policy => policy.RequireClaim("Rank", "P3"));
options.AddPolicy("RankClaimM3", policy => policy.RequireClaim("Rank", "M3"));
We added two policies: RankClaimP3 and RankClaimM3. In addition to requiring a Rank claim, they also require the value to be P3 and M3 respectively.
Similar to role-based authorization, we can also implement "Or" and "And" logic policies:
options.AddPolicy("RankClaimP3OrM3", policy => policy.RequireClaim("Rank", "P3", "M3"));
options.AddPolicy("RankClaimP3AndM3", policy => policy.RequireClaim("Rank", "P3").RequireClaim("Rank", "M3"));
The policy RankClaimP3OrM3 requires the user to have a Rank claim with value P3 or M3; the policy RankClaimP3AndM3 requires the user to have a Rank claim and the value must include both P3 and M3.
The usage of policies is similar to before (Note: policies cannot be separated by commas like roles):
// Only requires the user to have a "Rank" claim, value is irrelevant
[Authorize(Policy = "RankClaim")]
public string GetForRankClaim()
{
return "Rank claim only";
}
// Requires the user to have a "Rank" claim with value "M3"
[HttpGet("GetForRankClaimP3")]
[Authorize(Policy = "RankClaimP3")]
public string GetForRankClaimP3()
{
return "Rank claim P3";
}
// Requires the user to have a "Rank" claim with value "P3" or "M3"
[Authorize(Policy = "RankClaimP3OrM3")]
public string GetForRankClaimP3OrM3()
{
return "Rank claim P3 || M3";
}
There are two ways to express an "And" logic policy:
// Requires the user to have a "Rank" claim with values both "P3" and "M3"
[Authorize(Policy = "RankClaimP3AndM3")]
public string GetForRankClaimP3AndM3V1()
{
return "Rank claim P3 && M3";
}
// Requires the user to have a "Rank" claim with values both "P3" and "M3"
[Authorize(Policy = "RankClaimP3")]
[Authorize(Policy = "RankClaimM3")]
public string GetForRankClaimP3AndM3V2()
{
return "Rank claim P3 && M3";
}
Additionally, when a claims policy is slightly more complex, you can use RequireAssertion:
options.AddPolicy("ComplexClaim", policy => policy.RequireAssertion(context =>
context.User.HasClaim(c => (c.Type == "Rank" || c.Type == "Name") && c.Issuer == "Issuer")));
4. Policy-based Authorization
Sample program: XXTk.Auth.Samples.PolicyBased.HttpApi
Generally, the two authorization methods above are only suitable for relatively simple business scenarios. When the business scenario is more complex, they become insufficient. Therefore, we need to be able to design more flexible policies, which is policy-based authorization.
For policy-based authorization, I plan to introduce it in two types: simple policies and dynamic policies.
4.1 Simple Policies
Above, when defining policies, we used a lot of RequireXXX methods. We also want to encapsulate custom policies. You could write extension methods, but I recommend using IAuthorizationRequirement and IAuthorizationHandler.
Now, let's imagine a scenario: internet café management. Minors under 18 are not allowed; only adults aged 18 and above can enter. For this, we need a requirement that sets a minimum age:
public class MinimumAgeRequirement : IAuthorizationRequirement
{
public MinimumAgeRequirement(int minimumAge) =>
MinimumAge = minimumAge;
public int MinimumAge { get; }
}
Now that we have the requirement, we also need an authorization handler to verify whether the user has indeed reached the specified age:
public class MinimumAgeAuthorizationHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
{
// The birthday information can be obtained from other sources, such as a database, not limited to claims
var dateOfBirthClaim = context.User.FindFirst(c => c.Type == ClaimTypes.DateOfBirth);
if (dateOfBirthClaim is null)
{
return Task.CompletedTask;
}
var today = DateTime.Today;
var dateOfBirth = Convert.ToDateTime(dateOfBirthClaim.Value);
int calculatedAge = today.Year - dateOfBirth.Year;
if (dateOfBirth > today.AddYears(-calculatedAge))
{
calculatedAge--;
}
// If the age meets the minimum age requirement, authorization succeeds
if (calculatedAge >= requirement.MinimumAge)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
When verification passes, call context.Succeed to indicate authorization success. When verification fails, we have two approaches:
- One is to simply return
Task.CompletedTask, which allows subsequent Handlers to continue verification. If any of those Handlers succeed, the user is considered authorized. - The other is to call
context.Failto indicate authorization failure, and subsequent Handlers will still execute (even if a later Handler succeeds, it is considered a failure). If you want to stop immediately after callingcontext.Failand not execute subsequent Handlers, you can set theAuthorizationOptionspropertyInvokeHandlersAfterFailuretofalse. The default istrue.
Now, let's add an authorization logic to our fictional scenario: if the user is under 18 but has the role of internet café owner, they are still allowed to enter.
To implement this logic, we add another authorization handler:
public class MinimumAgeAnotherAuthorizationHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
{
var isBoss = context.User.IsInRole("InternetBarBoss");
if (isBoss)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
We have implemented both the authorization requirement and the authorization handler. Next, we need to add the policy. But before that, don't forget to inject our requirement and authorization handler:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.TryAddEnumerable(ServiceDescriptor.Transient<IAuthorizationHandler, MinimumAgeAuthorizationHandler>());
services.TryAddEnumerable(ServiceDescriptor.Transient<IAuthorizationHandler, MinimumAgeAnotherAuthorizationHandler>());
services.AddAuthorization(options =>
{
options.AddPolicy("AtLeast18Age", policy => policy.Requirements.Add(new MinimumAgeRequirement(18)));
});
}
}
Note that we can register Handlers with any lifetime. However, when a Handler depends on other services, you must be aware of the lifetime upgrade issue.
We added a policy named AtLeast18Age, which creates a MinimumAgeRequirement instance with a minimum age of 18 and adds it to the Requirements property of the policy.
You can write a similar endpoint for testing:
[Authorize(Policy = "AtLeast18Age")]
public string GetForAtLeast18Age()
{
return "At least 18 age";
}
Finally, a quick note: if you want a single Handler to handle multiple Requirements, you can do it like this:
public class MultiRequirementsAuthorizationHandler : IAuthorizationHandler
{
public Task HandleAsync(AuthorizationHandlerContext context)
{
var pendingRequirements = context.PendingRequirements;
foreach (var requirement in pendingRequirements)
{
if (requirement is Custom1Requirement)
{
// ... some verification
context.Succeed(requirement);
}
else if (requirement is Custom2Requirement)
{
// ... some verification
context.Succeed(requirement);
}
}
return Task.CompletedTask;
}
}
public class Custom1Requirement : IAuthorizationRequirement
{
}
public class Custom2Requirement : IAuthorizationRequirement
{
}
4.2 Dynamic Policies
Now, a new problem arises: what if our scenarios have multiple age restrictions? For example, some require 18, some require 20, and some only require 10. We can't create all these policies in advance one by one—that would be tedious. It would be great if we could dynamically create policies!
Let's try to dynamically create multiple minimum age policies:
First, inherit from AuthorizeAttribute to implement a custom authorization attribute MinimumAgeAuthorizeAttribute:
public class MinimumAgeAuthorizeAttribute : AuthorizeAttribute
{
// Policy name prefix
public const string PolicyPrefix = "MinimumAge";
// Pass the minimum age through the constructor
public MinimumAgeAuthorizeAttribute(int minimumAge) =>
MinimumAge = minimumAge;
public int MinimumAge
{
get
{
// Parse the minimum age from the policy name
if (int.TryParse(Policy[PolicyPrefix.Length..], out var age))
{
return age;
}
return default;
}
set
{
// Generate a dynamic policy name, e.g., MinimumAge18 for a minimum age of 18
Policy = $"{PolicyPrefix}{value}";
}
}
}
The logic is simple: concatenate the policy name prefix with the passed minimum age parameter to form a dynamic policy name, and the minimum age can be parsed back from the policy name.
Now that the policy name can be dynamically created, the next step is to dynamically create a policy instance based on the policy name. This can be achieved by replacing the default implementation of the IAuthorizationPolicyProvider interface:
public class AppAuthorizationPolicyProvider : IAuthorizationPolicyProvider
{
// Referenced from third-party library Nito.AsyncEx
private static readonly AsyncLock _mutex = new();
private readonly AuthorizationOptions _authorizationOptions;
public AppAuthorizationPolicyProvider(IOptions<AuthorizationOptions> options)
{
BackupPolicyProvider = new DefaultAuthorizationPolicyProvider(options);
_authorizationOptions = options.Value;
}
// Use default implementation if no custom logic is needed
private DefaultAuthorizationPolicyProvider BackupPolicyProvider { get; }
public async Task<AuthorizationPolicy> GetPolicyAsync(string policyName)
{
if(policyName is null) throw new ArgumentNullException(nameof(policyName));
// If the policy instance already exists, return it directly
var policy = await BackupPolicyProvider.GetPolicyAsync(policyName);
if(policy is not null)
{
return policy;
}
using (await _mutex.LockAsync())
{
var policy = await BackupPolicyProvider.GetPolicyAsync(policyName);
if(policy is not null)
{
return policy;
}
if (policyName.StartsWith(MinimumAgeAuthorizeAttribute.PolicyPrefix, StringComparison.OrdinalIgnoreCase)
&& int.TryParse(policyName[MinimumAgeAuthorizeAttribute.PolicyPrefix.Length..], out var age))
{
// Dynamically create the policy
var builder = new AuthorizationPolicyBuilder();
// Add the Requirement
builder.AddRequirements(new MinimumAgeRequirement(age));
policy = builder.Build();
// Add the policy to the options
_authorizationOptions.AddPolicy(policyName, policy);
return policy;
}
}
return null;
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
{
return BackupPolicyProvider.GetDefaultPolicyAsync();
}
public Task<AuthorizationPolicy> GetFallbackPolicyAsync()
{
return BackupPolicyProvider.GetFallbackPolicyAsync();
}
}
Finally, just inject the service:
services.AddTransient<IAuthorizationPolicyProvider, AppAuthorizationPolicyProvider>();
Now you can use MinimumAgeAuthorizeAttribute for authorization. For example, to require a minimum age of 20:
[MinimumAgeAuthorize(20)]
public string GetForAtLeast20Age()
{
return "At least 20 age";
}
5. Design Principles
Now that we have covered the basic usage, let's learn about the principles behind it.
Due to the amount of source code involved, only the core code is listed below to keep the article length manageable.
First, let's revisit AuthorizeAttribute:
public interface IAuthorizeData
{
// Policy
string? Policy { get; set; }
// Roles, multiple roles can be separated by commas to form a list
string? Roles { get; set; }
// Authentication schemes, multiple schemes can be separated by commas to form a list
string? AuthenticationSchemes { get; set; }
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public class AuthorizeAttribute : Attribute, IAuthorizeData
{
public AuthorizeAttribute() { }
public AuthorizeAttribute(string policy)
{
Policy = policy;
}
public string? Policy { get; set; }
public string? Roles { get; set; }
public string? AuthenticationSchemes { get; set; }
}
The Attribute part is obvious; note that AuthorizeAttribute implements the IAuthorizeData interface.
Next, let's start from services.AddAuthorization to see which services are registered for authorization:
You might wonder: even if I don't explicitly add the line services.AddAuthorization, the program won't throw an error. Actually, as we mentioned earlier in the Startup section, services.AddControllers() calls AddAuthorization by default.
public static IServiceCollection AddAuthorization(this IServiceCollection services)
{
services.AddAuthorizationCore();
services.AddAuthorizationPolicyEvaluator();
return services;
}
public static IServiceCollection AddAuthorizationCore(this IServiceCollection services)
{
services.AddOptions();
services.TryAdd(ServiceDescriptor.Transient<IAuthorizationService, DefaultAuthorizationService>());
services.TryAdd(ServiceDescriptor.Transient<IAuthorizationPolicyProvider, DefaultAuthorizationPolicyProvider>());
services.TryAdd(ServiceDescriptor.Transient<IAuthorizationHandlerProvider, DefaultAuthorizationHandlerProvider>());
services.TryAdd(ServiceDescriptor.Transient<IAuthorizationEvaluator, DefaultAuthorizationEvaluator>());
services.TryAdd(ServiceDescriptor.Transient<IAuthorizationHandlerContextFactory, DefaultAuthorizationHandlerContextFactory>());
services.TryAddEnumerable(ServiceDescriptor.Transient<IAuthorizationHandler, PassThroughAuthorizationHandler>());
return services;
}
public static IServiceCollection AddAuthorizationPolicyEvaluator(this IServiceCollection services)
{
services.TryAddSingleton<AuthorizationPolicyMarkerService>();
services.TryAddTransient<IPolicyEvaluator, PolicyEvaluator>();
services.TryAddTransient<IAuthorizationMiddlewareResultHandler, AuthorizationMiddlewareResultHandler>();
return services;
}
Let's organize the interfaces registered here:
- IAuthorizationService
- IAuthorizationPolicyProvider
- IAuthorizationHandlerProvider
- IAuthorizationEvaluator
- IAuthorizationHandlerContextFactory
- IAuthorizationHandler
- AuthorizationPolicyMarkerService
- IPolicyEvaluator
- IAuthorizationMiddlewareResultHandler
Some of these interfaces we've already seen, such as IAuthorizationPolicyProvider and IAuthorizationHandler. Don't worry about the roles of the others just yet. Let's look at AuthorizationOptions:
public class AuthorizationOptions
{
// Stores added policies, policy names are case-insensitive
private Dictionary<string, AuthorizationPolicy> PolicyMap { get; } = new Dictionary<string, AuthorizationPolicy>(StringComparer.OrdinalIgnoreCase);
// Whether subsequent IAuthorizationHandlers should still execute after authorization failure
public bool InvokeHandlersAfterFailure { get; set; } = true;
// Default policy: authenticated users
public AuthorizationPolicy DefaultPolicy { get; set; } = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
// Fallback policy
public AuthorizationPolicy? FallbackPolicy { get; set; }
public void AddPolicy(string name, AuthorizationPolicy policy)
{
PolicyMap[name] = policy;
}
public void AddPolicy(string name, Action<AuthorizationPolicyBuilder> configurePolicy)
{
var policyBuilder = new AuthorizationPolicyBuilder();
configurePolicy(policyBuilder);
PolicyMap[name] = policyBuilder.Build();
}
public AuthorizationPolicy? GetPolicy(string name)
{
if (PolicyMap.TryGetValue(name, out var value))
{
return value;
}
return null;
}
}
The default policy and fallback policy are different:
- Default policy: The policy to use when an endpoint is marked with
Authorizebut no specific policy is specified. - Fallback policy: The policy to use when an endpoint is not marked with
Authorize. This value can be null.
Next, let's look at the middleware registration app.UseAuthorization():
public static class AuthorizationAppBuilderExtensions
{
public static IApplicationBuilder UseAuthorization(this IApplicationBuilder app)
{
VerifyServicesRegistered(app);
return app.UseMiddleware<AuthorizationMiddleware>();
}
private static void VerifyServicesRegistered(IApplicationBuilder app)
{
if (app.ApplicationServices.GetService(typeof(AuthorizationPolicyMarkerService)) == null)
{
throw new InvalidOperationException(...);
}
}
}
internal class AuthorizationPolicyMarkerService
{
}
From this, we see that the role of AuthorizationPolicyMarkerService is to ensure that before registering the authorization middleware, we have already called UseAuthorization and registered all necessary services.
Now, let's dive into the implementation of AuthorizationMiddleware:
public class AuthorizationMiddleware
{
private const string SuppressUseHttpContextAsAuthorizationResource = "Microsoft.AspNetCore.Authorization.SuppressUseHttpContextAsAuthorizationResource";
private readonly RequestDelegate _next;
private readonly IAuthorizationPolicyProvider _policyProvider;
public AuthorizationMiddleware(RequestDelegate next, IAuthorizationPolicyProvider policyProvider)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_policyProvider = policyProvider ?? throw new ArgumentNullException(nameof(policyProvider));
}
public async Task Invoke(HttpContext context)
{
var endpoint = context.GetEndpoint();
// ... omitted some code
// AuthorizeAttribute implements the IAuthorizeData interface; from here we can get our authorization data
var authorizeData = endpoint?.Metadata.GetOrderedMetadata<IAuthorizeData>() ?? Array.Empty<IAuthorizeData>();
// 1. Assemble all authorization requirements into one policy instance
var policy = await AuthorizationPolicy.CombineAsync(_policyProvider, authorizeData);
// No authorization policy, skip authorization check
if (policy == null)
{
await _next(context);
return;
}
// IPolicyEvaluator's default lifetime is Transient, while this middleware's lifetime is Singleton,
// so it's not recommended to inject this service into the constructor
var policyEvaluator = context.RequestServices.GetRequiredService<IPolicyEvaluator>();
// 2. Authenticate
var authenticateResult = await policyEvaluator.AuthenticateAsync(policy, context);
// 3. If the endpoint is marked with AllowAnonymousAttribute, skip authorization
if (endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null)
{
await _next(context);
return;
}
object? resource;
if (AppContext.TryGetSwitch(SuppressUseHttpContextAsAuthorizationResource, out var useEndpointAsResource) && useEndpointAsResource)
{
resource = endpoint;
}
else
{
resource = context;
}
// 4. Authorize
var authorizeResult = await policyEvaluator.AuthorizeAsync(policy, authenticateResult, context, resource);
// 5. Handle different authorization results accordingly
var authorizationMiddlewareResultHandler = context.RequestServices.GetRequiredService<IAuthorizationMiddlewareResultHandler>();
await authorizationMiddlewareResultHandler.HandleAsync(_next, context, policy, authorizeResult);
}
}
From this, we can see that all authorization methods are implemented based on policies.
Let's analyze step by step. First, step 1: understand how multiple authorization requirements are assembled into one policy:
public class AuthorizationPolicy
{
public static async Task<AuthorizationPolicy?> CombineAsync(IAuthorizationPolicyProvider policyProvider, IEnumerable<IAuthorizeData> authorizeData)
{
// ... omitted some code
AuthorizationPolicyBuilder? policyBuilder = null;
foreach (var authorizeDatum in authorizeData)
{
if (policyBuilder == null)
{
policyBuilder = new AuthorizationPolicyBuilder();
}
// First handle the policy
var useDefaultPolicy = true;
if (!string.IsNullOrWhiteSpace(authorizeDatum.Policy))
{
// Get the policy instance by policy name
var policy = await policyProvider.GetPolicyAsync(authorizeDatum.Policy);
if (policy == null)
{
throw new InvalidOperationException(...);
}
policyBuilder.Combine(policy);
useDefaultPolicy = false;
}
// Then handle roles
var rolesSplit = authorizeDatum.Roles?.Split(',');
if (rolesSplit?.Length > 0)
{
var trimmedRolesSplit = rolesSplit.Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r.Trim());
// Add role requirements to the policy
policyBuilder.RequireRole(trimmedRolesSplit);
useDefaultPolicy = false;
}
// Finally handle authentication schemes
var authTypesSplit = authorizeDatum.AuthenticationSchemes?.Split(',');
if (authTypesSplit?.Length > 0)
{
foreach (var authType in authTypesSplit)
{
if (!string.IsNullOrWhiteSpace(authType))
{
// Add authentication scheme requirements to the policy
policyBuilder.AuthenticationSchemes.Add(authType.Trim());
}
}
}
if (useDefaultPolicy)
{
// Add the default policy
policyBuilder.Combine(await policyProvider.GetDefaultPolicyAsync());
}
}
// If there is still no policy, check if a fallback policy exists; if so, return it
if (policyBuilder == null)
{
var fallbackPolicy = await policyProvider.GetFallbackPolicyAsync();
if (fallbackPolicy != null)
{
return fallbackPolicy;
}
}
// Return the assembled policy instance
return policyBuilder?.Build();
}
}
The overall logic is explained in the comments, so I won't elaborate further. Let's look at IAuthorizationPolicyProvider, which we encountered earlier and is used here as well:
public interface IAuthorizationPolicyProvider
{
Task<AuthorizationPolicy?> GetPolicyAsync(string policyName);
Task<AuthorizationPolicy> GetDefaultPolicyAsync();
Task<AuthorizationPolicy?> GetFallbackPolicyAsync();
}
As the name suggests, this interface is used to provide authorization policy instances.
The interface has three methods:
GetPolicyAsync: Gets a policy instance by policy name.GetDefaultPolicyAsync: Gets the default policy. When we specify that authorization should be performed but haven't set any authorization requirements (such as policy name, roles, authentication schemes, etc.), the default policy is used.GetFallbackPolicyAsync: Gets the fallback policy. When we haven't specified any authorization at all, the fallback policy is used. If it is null, authorization is skipped.
Now let's look at the default implementation of this interface, DefaultAuthorizationPolicyProvider:
public class DefaultAuthorizationPolicyProvider : IAuthorizationPolicyProvider
{
private readonly AuthorizationOptions _options;
private Task<AuthorizationPolicy>? _cachedDefaultPolicy;
private Task<AuthorizationPolicy?>? _cachedFallbackPolicy;
public DefaultAuthorizationPolicyProvider(IOptions<AuthorizationOptions> options)
{
_options = options.Value;
}
public virtual Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
// Look up the policy instance from AuthorizationOptions
return Task.FromResult(_options.GetPolicy(policyName));
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
{
// Get the DefaultPolicy configured in AuthorizationOptions
if (_cachedDefaultPolicy == null || _cachedDefaultPolicy.Result != _options.DefaultPolicy)
{
_cachedDefaultPolicy = Task.FromResult(_options.DefaultPolicy);
}
return _cachedDefaultPolicy;
}
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
{
// Get the FallbackPolicy configured in AuthorizationOptions
if (_cachedFallbackPolicy == null || _cachedFallbackPolicy.Result != _options.FallbackPolicy)
{
_cachedFallbackPolicy = Task.FromResult(_options.FallbackPolicy);
}
return _cachedFallbackPolicy;
}
}
OK, that's enough about IAuthorizationPolicyProvider.
Let's return to AuthorizationMiddleware and continue to step 2, where we encounter a new interface IPolicyEvaluator:
public interface IPolicyEvaluator
{
Task<AuthenticateResult> AuthenticateAsync(AuthorizationPolicy policy, HttpContext context);
Task<PolicyAuthorizationResult> AuthorizeAsync(AuthorizationPolicy policy, AuthenticateResult authenticationResult, HttpContext context, object? resource);
}
This interface is used to evaluate authentication and authorization results, producing AuthenticateResult and PolicyAuthorizationResult respectively.
The interface has two methods:
AuthenticateAsync: Performs authentication based on the schemes provided in the policy and generates an authentication result.AuthorizeAsync: Performs authorization based on the policy and authentication result and generates an authorization result.
The default implementation of this interface is PolicyEvaluator:
public class PolicyEvaluator : IPolicyEvaluator
{
private readonly IAuthorizationService _authorization;
public PolicyEvaluator(IAuthorizationService authorization)
{
_authorization = authorization;
}
public virtual async Task<AuthenticateResult> AuthenticateAsync(AuthorizationPolicy policy, HttpContext context)
{
// The policy specifies authentication schemes
if (policy.AuthenticationSchemes != null && policy.AuthenticationSchemes.Count > 0)
{
// Merge the results of multiple authentication schemes
ClaimsPrincipal? newPrincipal = null;
foreach (var scheme in policy.AuthenticationSchemes)
{
var result = await context.AuthenticateAsync(scheme);
if (result != null && result.Succeeded)
{
newPrincipal = SecurityHelper.MergeUserPrincipal(newPrincipal, result.Principal);
}
}
if (newPrincipal != null)
{
context.User = newPrincipal;
return AuthenticateResult.Success(new AuthenticationTicket(newPrincipal, string.Join(";", policy.AuthenticationSchemes)));
}
else
{
context.User = new ClaimsPrincipal(new ClaimsIdentity());
return AuthenticateResult.NoResult();
}
}
// Check if the default authentication scheme is authenticated
return (context.User?.Identity?.IsAuthenticated ?? false)
? AuthenticateResult.Success(new AuthenticationTicket(context.User, "context.User"))
: AuthenticateResult.NoResult();
}
public virtual async Task<PolicyAuthorizationResult> AuthorizeAsync(AuthorizationPolicy policy, AuthenticateResult authenticationResult, HttpContext context, object? resource)
{
var result = await _authorization.AuthorizeAsync(context.User, resource, policy);
if (result.Succeeded)
{
return PolicyAuthorizationResult.Success();
}
// On authorization failure:
// If authentication succeeded, return Forbid
// If authentication failed, issue a challenge
return (authenticationResult.Succeeded)
? PolicyAuthorizationResult.Forbid(result.Failure)
: PolicyAuthorizationResult.Challenge();
}
}
From this, we can see that if the default authentication scheme cannot provide complete authentication, you can specify AuthenticationSchemes in IAuthorizeData to re-authenticate.
This uses a new interface, IAuthorizationService. As the name implies, it is a service interface specifically for authorization. The actual authorization logic is encapsulated in its implementation class. Let's look at its definition:
public interface IAuthorizationService
{
Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, IEnumerable<IAuthorizationRequirement> requirements);
Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName);
}
This interface has two overloads of the AuthorizeAsync method:
- Checks whether the user satisfies the specific requirements for the given resource.
- Checks whether the user satisfies a specific authorization policy.
If you're observant, you might notice that these two overloads do not match the call in the code above, because the third parameter passed is of type AuthorizationPolicy. In fact, it's handled through an extension method.
public static class AuthorizationServiceExtensions
{
public static Task<AuthorizationResult> AuthorizeAsync(this IAuthorizationService service, ClaimsPrincipal user, object? resource, AuthorizationPolicy policy)
{
return service.AuthorizeAsync(user, resource, policy.Requirements);
}
}
So we know that it actually calls the first overload.
The default implementation of this interface is DefaultAuthorizationService:
public class DefaultAuthorizationService : IAuthorizationService
{
// All fields are injected via constructor
private readonly AuthorizationOptions _options;
private readonly IAuthorizationHandlerContextFactory _contextFactory;
private readonly IAuthorizationHandlerProvider _handlers;
private readonly IAuthorizationEvaluator _evaluator;
private readonly IAuthorizationPolicyProvider _policyProvider;
public virtual async Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, IEnumerable<IAuthorizationRequirement> requirements)
{
var authContext = _contextFactory.CreateContext(requirements, user, resource);
var handlers = await _handlers.GetHandlersAsync(authContext);
foreach (var handler in handlers)
{
await handler.HandleAsync(authContext);
// If configured to not call subsequent Handlers after authorization failure
if (!_options.InvokeHandlersAfterFailure && authContext.HasFailed)
{
break;
}
}
var result = _evaluator.Evaluate(authContext);
// omitted some code...
return result;
}
public virtual async Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName)
{
var policy = await _policyProvider.GetPolicyAsync(policyName);
if (policy == null)
{
throw new InvalidOperationException($"No policy found: {policyName}.");
}
return await this.AuthorizeAsync(user, resource, policy);
}
}
First, IAuthorizationHandlerContextFactory is used here to create the authorization handler context:
public interface IAuthorizationHandlerContextFactory
{
AuthorizationHandlerContext CreateContext(IEnumerable<IAuthorizationRequirement> requirements, ClaimsPrincipal user, object? resource);
}
public class DefaultAuthorizationHandlerContextFactory : IAuthorizationHandlerContextFactory
{
public virtual AuthorizationHandlerContext CreateContext(IEnumerable<IAuthorizationRequirement> requirements, ClaimsPrincipal user, object? resource)
{
return new AuthorizationHandlerContext(requirements, user, resource);
}
}
Then, IAuthorizationHandlerProvider is used to provide Handlers, including our previously implemented MinimumAgeAuthorizationHandler, etc.
public interface IAuthorizationHandlerProvider
{
Task<IEnumerable<IAuthorizationHandler>> GetHandlersAsync(AuthorizationHandlerContext context);
}
public class DefaultAuthorizationHandlerProvider : IAuthorizationHandlerProvider
{
private readonly IEnumerable<IAuthorizationHandler> _handlers;
public DefaultAuthorizationHandlerProvider(IEnumerable<IAuthorizationHandler> handlers)
{
_handlers = handlers;
}
public Task<IEnumerable<IAuthorizationHandler>> GetHandlersAsync(AuthorizationHandlerContext context)
=> Task.FromResult(_handlers);
}
Additionally, IAuthorizationEvaluator is used to evaluate whether the authorization result is success or failure and construct an AuthorizationResult instance.
public interface IAuthorizationEvaluator
{
AuthorizationResult Evaluate(AuthorizationHandlerContext context);
}
public class DefaultAuthorizationEvaluator : IAuthorizationEvaluator
{
public AuthorizationResult Evaluate(AuthorizationHandlerContext context)
=> context.HasSucceeded
? AuthorizationResult.Success()
: AuthorizationResult.Failed(context.HasFailed
? AuthorizationFailure.ExplicitFail()
: AuthorizationFailure.Failed(context.PendingRequirements));
}
Finally, after obtaining the AuthorizationResult, we reach step 5, where IAuthorizationMiddlewareResultHandler handles different authorization results accordingly.
public interface IAuthorizationMiddlewareResultHandler
{
Task HandleAsync(RequestDelegate next, HttpContext context, AuthorizationPolicy policy, PolicyAuthorizationResult authorizeResult);
}
public class AuthorizationMiddlewareResultHandler : IAuthorizationMiddlewareResultHandler
{
public async Task HandleAsync(RequestDelegate next, HttpContext context, AuthorizationPolicy policy, PolicyAuthorizationResult authorizeResult)
{
// Need to issue a challenge
if (authorizeResult.Challenged)
{
if (policy.AuthenticationSchemes.Count > 0)
{
foreach (var scheme in policy.AuthenticationSchemes)
{
await context.ChallengeAsync(scheme);
}
}
else
{
await context.ChallengeAsync();
}
return;
}
// Need to respond with 403
else if (authorizeResult.Forbidden)
{
if (policy.AuthenticationSchemes.Count > 0)
{
foreach (var scheme in policy.AuthenticationSchemes)
{
await context.ForbidAsync(scheme);
}
}
else
{
await context.ForbidAsync();
}
return;
}
// Authorization successful, continue the pipeline
await next(context);
}
}
At this point, all services registered in the container have been covered. Let's summarize again:
AuthorizationPolicyMarkerService: A marker to indicate thatUseAuthorizationhas been called and all required authorization services have been registered.IAuthorizationService: Default implementationDefaultAuthorizationService, used to authorize users.IAuthorizationHandlerContextFactory: Default implementationDefaultAuthorizationHandlerContextFactory, used to create the authorization handler context.IAuthorizationHandlerProvider: Default implementationDefaultAuthorizationHandlerProvider, used to provide authorization handlers (IAuthorizationHandler).IAuthorizationHandler: Default implementationPassThroughAuthorizationHandler(handles classes that are both Requirement and Handler), used to provide Requirement handling logic.IAuthorizationPolicyProvider: Default implementationDefaultAuthorizationPolicyProvider, used to provide authorization policy instances (AuthorizationPolicy).IAuthorizationEvaluator: Default implementationDefaultAuthorizationEvaluator, used to evaluate whether the authorization result is success or failure and construct anAuthorizationResultinstance.IPolicyEvaluator: Default implementationPolicyEvaluator, used to evaluate authentication and authorization results.IAuthorizationMiddlewareResultHandler: Default implementationAuthorizationMiddlewareResultHandler, used to handle different authorization results accordingly.
Now, when you need to implement custom operations, simply override the corresponding interface implementation.
To help you understand, I have drawn a diagram showing the calling relationships of the various interfaces:

Finally, you probably know that there's another place where you can control permissions: the IAuthorizationFilter filter. However, unless necessary, I don't recommend using it. It is an outdated artifact from the MVC era, and you would have to implement a complete authorization framework yourself.
6. Supplement
Based on my experience, a commonly used authorization scheme is based on permission keys. To that end, I have also written a simple sample program for your reference: XXTk.Auth.Samples.Permission.HttpApi