Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to correctly setup Policy Authorization for WEB API in .NET Core

I have this Web API project, with no UI. My appsettings.json file has a section listing tokens and which client they belong to. So the client will need to just present a matching token in the header. If no token is presented or an invalid one, then it should be returning a 401.

In ConfigureServices I setup authorization

.AddTransient<IAuthorizationRequirement, ClientTokenRequirement>()
.AddAuthorization(opts => opts.AddPolicy(SecurityTokenPolicy, policy =>
 {
       var sp = services.BuildServiceProvider();
       policy.Requirements.Add(sp.GetService<IAuthorizationRequirement>());
 }))

This part fires correctly from what I can see. Here is code for the ClientTokenRequirement

protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ClientTokenRequirement requirement)
    {
        if (context.Resource is AuthorizationFilterContext authFilterContext)
        {
            if (string.IsNullOrWhiteSpace(_tokenName))
                throw new UnauthorizedAccessException("Token not provided");

            var httpContext = authFilterContext.HttpContext;

            if (!httpContext.Request.Headers.TryGetValue(_tokenName, out var tokenValues))
                return Task.CompletedTask;

            var tokenValueFromHeader = tokenValues.FirstOrDefault();

            var matchedToken = _tokens.FirstOrDefault(t => t.Token == tokenValueFromHeader);

            if (matchedToken != null)
            {       
                httpContext.Succeed(requirement);
            }
        }

        return Task.CompletedTask;
    }

When we are in the ClientTokenRequirement and have not matched a token it returns

return Task.CompletedTask;

This is done how it is documented at https://learn.microsoft.com/en-us/aspnet/core/security/authorization/policies?view=aspnetcore-2.1

This works correctly when there is a valid token, but when there isnt and it returns Task.Completed, there is no 401 but an exception instead

InvalidOperationException: No authenticationScheme was specified, and there was no DefaultChallengeScheme found.

I have read other stackoverflow articles about using Authentication rather than Authorization, but really this policy Authorization is the better fit for purpose. So I am looking for ideas on how to prevent this exception.

like image 697
Lotok Avatar asked Dec 24 '22 04:12

Lotok


1 Answers

Interestingly, I think this is just authentication, without any authorisation (at least not in your question). You certainly want to authenticate the client but you don't appear to have any authorisation requirements. Authentication is the process of determining who is making this request and authorisation is the process of determining what said requester can do once we know who it is (more here). You've indicated that you want to return a 401 (bad credentials) rather than a 403 (unauthorised), which I believe highlights the difference (more here).

In order to use your own authentication logic in ASP.NET Core, you can write your own AuthenticationHandler, which is responsible for taking a request and determining the User. Here's an example for your situation:

public class ClientTokenHandler : AuthenticationHandler<ClientTokenOptions>
{
    private readonly string[] _clientTokens;

    public ClientTokenHandler(IOptionsMonitor<ClientTokenOptions> optionsMonitor,
        ILoggerFactory loggerFactory, UrlEncoder urlEncoder, ISystemClock systemClock,
        IConfiguration config)
        : base(optionsMonitor, loggerFactory, urlEncoder, systemClock)
    {
        _clientTokens = config.GetSection("ClientTokens").Get<string[]>();
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var tokenHeaderValue = (string)Request.Headers["X-TOKEN"];

        if (string.IsNullOrWhiteSpace(tokenHeaderValue))
            return Task.FromResult(AuthenticateResult.NoResult());

        if (!_clientTokens.Contains(tokenHeaderValue))
            return Task.FromResult(AuthenticateResult.Fail("Unknown Client"));

        var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(
            Enumerable.Empty<Claim>(),
            Scheme.Name));
        var authenticationTicket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);

        return Task.FromResult(AuthenticateResult.Success(authenticationTicket));
    }
}

Here's a description of what's going on in HandleAuthenticateAsync:

  1. The header X-TOKEN is retrieved from the request. If this is invalid, we indicate that we are unable to authenticate the request (more on this later).
  2. The value retrieved from the X-TOKEN header is compared against a known list of client-tokens. If this is unsuccessful, we indicate that authentication failed (we don't know who this is - more on this later too).
  3. When a client-token matches the X-TOKEN request header, we create a new AuthenticationTicket/ClaimsPrincipal/ClaimsIdentity combo. This is our representation of the User - you can include your own Claims instead of using Enumerable.Empty<Claim>() if you want to associate additional information with the client.

You should be able to use this as-is for the most part, with a few changes (I've simplified to both keep the answer short and fill in a few gaps from the question):

  1. The constructor takes an instance of IConfiguration as the final parameter, which is then used to read a string[] from, in my example, appsettings.json. You are likely doing this differently, so you can just use DI to inject whatever it is you're currently using here, as needed.
  2. I've hardcoded X-TOKEN as the header name to use when extracting the token. You'll likely be using a different name for this yourself and I can see from your question that you're not hardcoding it, which is better.

One other thing to note about this implementation is the use of both AuthenticateResult.NoResult() and AuthenticateResult.Fail(...). The former indicates that we did not have enough information in order to perform the authentication and the latter indicates that we had everything we needed but the authentication failed. For a simple setup like yours, I think you'd be OK using Fail in both cases if you'd prefer.

The second thing you'll need is the ClientTokenOptions class, which is used above in AuthenticationHandler<ClientTokenOptions>. For this example, this is a one-liner:

public class ClientTokenOptions : AuthenticationSchemeOptions { }

This is used for configuring your AuthenticationHandler - feel free to move some of the configuration into here (e.g. the _clientTokens from above). It also depends on how configurable and reusable you want this to be - as another example, you could define the header name in here, but that's up to you.

Lastly, to use your ClientTokenHandler, you'll need to add the following to ConfigureServices:

services.AddAuthentication("ClientToken")
    .AddScheme<ClientTokenOptions, ClientTokenHandler>("ClientToken", _ => { });

Here, we're just registering ClientTokenHandler as an AuthenticationHandler under our own custom ClientToken scheme. I wouldn't hardcode "ClientToken" here like this, but, again, this is just a simplification. The funky _ => { } at the end is a callback that is given an instance of ClientTokenOptions to modify: we don't need that here, so it's just an empty lambda, effectively.

InvalidOperationException: No authenticationScheme was specified, and there was no DefaultChallengeScheme found.

The "DefaultChallengeScheme" in your error message has now been set with the call to services.AddAuthentication("ClientToken") above ("ClientToken" is the scheme name).


If you want to go with this approach, you'll need to remove your ClientTokenRequirement stuff. You might also find it interesting to have a look through Barry Dorrans's BasicAuthentication project - it follows the same patterns as the official ASP.NET Core AuthenticationHandlers but is simpler for getting started. If you're not concerned about the configurability and reusability aspects, the implementation I've provided should be fit for purpose.

like image 158
Kirk Larkin Avatar answered Jan 02 '23 05:01

Kirk Larkin