DEV Community

Dennis
Dennis

Posted on

How to secure your Umbraco api controller with an api key

Setting up an api endpoint in Umbraco 10 and above is incredibly easy: Create a class, inherit from UmbracoApiController and you're done. Did you know it's also really easy to secure your api with an api key (or any form of authentication really)? In this article, I'm going to show you how to apply api key authentication to your api endpoints in Umbraco 10 and above.

Introduction to authentication

Before we dive into the code, let's have a quick look at what we're working with.

Access to secured resources is separated into two steps: authentication and authorization. There are plenty of resources online that can explain in depth how these concepts work, like this one, so I won't go into detail. In short: Authentication is the process of verifying the user's identity. It answers the question: Who are you? Authorization is the process of verifying the user's permission. It answers the question: Are you allowed to do this?

In .NET 6 and up, authentication is handled by authentication handlers. These handlers take many forms, but it all comes down to the same thing: An authentication handler uses the properties of an incoming request to determine who you are and it collects a set of properties that together form your identity. If an authentication handler cannot figure out who you are, it will return a challenge with instructions on how to identify yourself.

Authorization in .NET 6 is implemented with so called policies. Policies are lists of requirements that need to be met if you want to access a resource. These requirements are validated using authorization handlers. These authorization handlers use the identity from the authentication step and compare it with all the requirements. Usually, an authorization handler handles one specific requirement and multiple handlers are used in a single request.

For our api key security, we're going to build an authentication handler. The api key will be the characteristic in our request that tells the application that we are a legit user. That is enough to protect our endpoints, but I'll also show how to create custom policies to enhance your security even further.

With that out of the way, let's get started on the code.

The code

For the purpose of illustration, We're going to work with the following api controller:

public class DemoController : UmbracoApiController
{
    [HttpPost]
    public IActionResult SecuredAction()
    {
        return Ok("You have access to this endpoint!");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now if we do a post request to /umbraco/api/demo/securedaction, we get the following result:

Screenshot from PostMan showing that I have anonymous access to the new api controller

Let's make this endpoint secure:

Configurations

It's easiest to start with the configurations. We create the following files:

ApiKeyAuthenticationDefaults.cs

public class ApiKeyAuthenticationDefaults
{
    // 👇 This string identifies the authentication scheme for api keys. It can be anything you want.
    public const string AuthenticationScheme = "ApiKey";

    // 👇 This is the header from which the api key should be read
    public const string HeaderName = "apiKey";
}
Enter fullscreen mode Exit fullscreen mode

ApiKeyAuthenticationOptions.cs

public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
{
    // 👇 This is the value that the request header should have to be accepted
    public string ApiKey { get; set; }
}

Enter fullscreen mode Exit fullscreen mode

Now we should also add the following configuration in our appsettings:

appsettings.json

{
    "ApiConnection": {
        "ApiKey": "MySecretApiKey"
    }
}
Enter fullscreen mode Exit fullscreen mode

After these steps, we're ready to implement the authentication handler:

Authentication handler

For the authentication handler, we create the following file:

ApiKeyAuthenticationHandler.cs


public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    public ApiKeyAuthenticationHandler(
        IOptionsMonitor<ApiKeyAuthenticationOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ISystemClock clock)
        : base(options, logger, encoder, clock)
    { }

    // 👇 All our code will be synchronous, so let's leave all async stuff separate.
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
        => Task.FromResult(HandleAuthenticate());

    private AuthenticateResult HandleAuthenticate()
    {
        // 👇 We have to make sure that an api key is defined.
        // If the api key is not defined, the endpoint is potentially vulnerable, so this handler should stop early to prevent accidental exposure
        var apiKey = Options.ApiKey;
        if (string.IsNullOrWhiteSpace(apiKey))
        {
            Logger.LogError("The configured api key is empty or null. APIs with api key are closed off for security");
            return AuthenticateResult.NoResult();
        }

        // 👇 If the incoming request does not have the api key header, then it can't be authenticated with this handler, so the handler stops early
        if (!Request.Headers.ContainsKey(ApiKeyAuthenticationDefaults.HeaderName))
        {
            return AuthenticateResult.NoResult();
        }

        // 👇 If the incoming request does contain the api key header, then it's certain that they try to authenticate with this handler.
        // If the incoming api key does not match configuration, the authentication should actively fail.
        if (!Options.ApiKey.Equals(Request.Headers[ApiKeyAuthenticationDefaults.HeaderName]))
        {
            return AuthenticateResult.Fail("Invalid Key provided");
        }

        // 👇 If all the previous checks pass, then we know that the requester is legit.
        // Notice that these claims can pretty much be anything you want. The Claim of type 'ClaimTypes.Name' is required to have, but the rest is completely up to you.
        // In this example, I use the remote IP address as part of the identity. This will come in use later in the authorization step
        var claims = new[] { new Claim("origin", Request.HttpContext.Connection.RemoteIpAddress.ToString()), new Claim(ClaimTypes.Name, Request.HttpContext.Connection.RemoteIpAddress.ToString()) };
        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);
        return AuthenticateResult.Success(ticket);
    }
}
Enter fullscreen mode Exit fullscreen mode
⚠️ NOTE
Just because we've identified a user here, doesn't mean that they're allowed to access a resource. Remember: authentication is separate from authorization. After this step, we only know that the requester is a legit user, we don't know if they're allowed to access the resource yet

For convenience, we can create an extension method to help consumers register our new authentication handler:

public static class ApiKeyAuthenticationExtensions
{
    public static AuthenticationBuilder AddApiKey(this AuthenticationBuilder builder, Action<ApiKeyAuthenticationOptions>? configureOptions = null)
        => AddApiKey(builder, ApiKeyAuthenticationDefaults.AuthenticationScheme, configureOptions);

    public static AuthenticationBuilder AddApiKey(this AuthenticationBuilder builder, string authenticationScheme, Action<ApiKeyAuthenticationOptions>? configureOptions = null)
    {
        return builder.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(authenticationScheme, configureOptions);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we open up our Startup.cs and extend it like this:

public class Startup
{
    // ...

    public void ConfigureServices(IServiceCollection services)
    {
        // ...

        services.AddAuthentication(ApiKeyAuthenticationDefaults.AuthenticationScheme)
            .AddApiKey(options =>
            {
                // 👇 the api key is read from the appsettings.json here.
                options.ApiKey = _config.GetValue<string>("ApiConnection:ApiKey");
            });
    }
}
Enter fullscreen mode Exit fullscreen mode

The last step is to actually apply the authentication to the api endpoint. We'll change the controller to look like this:

public class DemoController : UmbracoApiController
{
    // 👇 We use the authentication schemes option here to say that users need to be authenticated with an api key before accessing this resource. There are no requirements for this user.
    [HttpPost, Authorize(AuthenticationSchemes = ApiKeyAuthenticationDefaults.AuthenticationScheme)]
    public IActionResult SecuredAction()
    {
        return Ok("You have access to this endpoint!");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now let's fire the same request at our api endpoint and check out the result:

Screenshot of PostMan receiving a 401 result without api key header

As you can see: The website returns 401 unauthenticated. That means: the website doesn't know who we are, so we're not allowed to access this resource.

Now let's add the api key header to the request and try again:

Screenshot of PostMan having access to the resource with the api key header

We have access! 🎉

If you only wanted to restrict access to the resource with an api key, you can stop here. There are more fascinating things that you can do though, so if you're interested, please keep reading:

Restrict access with a custom policy

With the new authentication handler, we know that only legit users can access the resource. Let's take this a step further though and require the incoming user to have a specific IP address. For this, we're going to dive into policies.

⚠️ NOTE
This example on IP restriction is only here to illustrate the concept. It has not been tested for security, so don't just copy and paste!

Configurations

For configuration, we're going to need the following files:

IPAddressPolicyDefaults.cs

public class IPAddressPolicyDefaults
{
    public const string LocalhostPolicy = "SourceFromLocalhost";
    public const string LocalhostIP = "::1";
}
Enter fullscreen mode Exit fullscreen mode

IPAddressRequirement.cs

public class IPAddressRequirement : IAuthorizationRequirement
{
    public IPAddressRequirement(string ipaddress)
    {
        SourceAddress = IPAddress.Parse(ipaddress);
    }

    public IPAddress SourceAddress { get; }
}
Enter fullscreen mode Exit fullscreen mode

Using these objects, we can define a policy with a proper requirement. We extend the Startup.cs file like this:

public class Startup
{
    // ...

    public void ConfigureServices(IServiceCollection services)
    {
        // ...

        services.AddAuthorization(options =>
        {
            options.AddPolicy(IPAddressPolicyDefaults.LocalhostPolicy, policy =>
            {
                policy.Requirements.Add(new IPAddressRequirement(IPAddressPolicyDefaults.LocalhostIP));
            });
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

This policy determines that a request should come from localhost.

Authorization handler

For this policy to work, we need something that can tell us if the requirement is met. This is done with an authorization handler like this:

IPAddressAuthorizationHandler.cs

// 👇 this handler specifically handles every ip address requirement
public class IPAddressAuthorizationHandler : AuthorizationHandler<IPAddressRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IPAddressRequirement requirement)
    {
        // 👇 We have knowledge about the user through their claims. Look back at the Authentication handler and notice that we set a value for the claim "origin" there. It is here that we read and use that value.
        var claim = context.User.Claims.FirstOrDefault(c => c.Type == "origin");

        // 👇 If there is no origin claim, then this handler cannot verify the ip address requirement
        if (claim is null) return Task.CompletedTask;

        // 👇 If the origin claim cannot be parsed as an IPAddress, then this handler also cannot verify the ip address requirement
        if (!IPAddress.TryParse(claim.Value, out var ipAddress)) return Task.CompletedTask;

        // 👇 If the ip address from the origin claim is not equal to the ip address of the requirement, then this handler still cannot verify the ip address requirement
        if (!ipAddress.Equals(requirement.SourceAddress)) return Task.CompletedTask;

        // 👇 If all the above checks pass, then this must be a valid ip address and the requirement is verified.
        context.Succeed(requirement);
        return Task.CompletedTask;
    }
}
Enter fullscreen mode Exit fullscreen mode

This handler must be registered in the dependency injection container in startup.cs:

public class Startup
{
    // ...

    public void ConfigureServices(IServiceCollection services)
    {
        // ...

        services.AddSingleton<IAuthorizationHandler, IPAddressAuthorizationHandler>();
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we need to update the controller to specify that only requests that comply with the policy are allowed to access the resource:

public class DemoController : UmbracoApiController
{
    [HttpPost, Authorize(AuthenticationSchemes = ApiKeyAuthenticationDefaults.AuthenticationScheme, Policy = IPAddressPolicyDefaults.LocalhostPolicy)]
    public IActionResult SecuredAction()
    {
        return Ok("You have access to this endpoint!");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now if we request the resource, we still have access, because we access the resource through our localhost address, but watch what happens when I change the ip address to a different value in the code:

Screenshot of PostMan receiving a 403 response because it does not connect through the expected ip address

The request is now rejected with an 403 Forbidden response! That means: the website knows who we are, but we don't meet the requirements to access this resource.

Final thoughts

If you've followed along with this tutorial, you will have created an api endpoint in Umbraco that is authenticated with an api key using a custom authentication handler and you'll have restricted the use of the api key by IP address using a custom authorization policy. That is just the beginning though!

Take a moment to realize what possibilities you have with these tools. Say we don't have one global api key, but a database with api keys related to users. When receiving an api key, you could look it up in a database and authenticate as that user. You could make different policies for each endpoint that you create and let users assign different permissions to their api keys. From here, the possibilities are pretty much endless!

That's all I wanted to share. I hope you found this useful. For now I'll say: thanks for reading and I look forward to see you in my next blog! 😊

Top comments (2)

Collapse
 
stef111 profile image
Stefanie Mills

This is the article I was looking for, so thank you for helping. Could you please tell me what software you use to run your incredibly fast website? I also want to create a simple website for my business, but I need help with the domain and hosting. Asphostportal reportedly has a stellar reputation. Are there any other choices available, and if so, what would you suggest?

Collapse
 
d_inventor profile image
Dennis

Hi Stefanie! I'm happy to hear that my writings are of help to you 😊

I'm not sure what you mean with "my incredibly fast website"? Most of the websites that I develop are hosted in azure cloud. Azure cloud is a great service, but I don't have enough experience with all the various hosting services out there to give you any decent advice on hosting.
I'd say, if you have the chance, just give one host a try and see if you like it or not. If it's your first time, just see how it goes and take note of the things that you like and the things you don't like. From there you can decide whether you want to stay or not and look for hosts that align more with your preferences.