How to secure your Umbraco api controller with an api key
Tags: webdev, beginners, dotnetcore, tutorial
Published: 2023-04-26 06:43:47

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](https://www.okta.com/identity-101/authentication-vs-authorization/), 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:

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

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/56kac0vruw4hejnke1ec.png)

Let's make this endpoint secure:

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

_ApiKeyAuthenticationDefaults.cs_
```csharp
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";
}
```

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

```

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

_appsettings.json_
```json
{
    "ApiConnection": {
        "ApiKey": "MySecretApiKey"
    }
}
```

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

### Authentication handler
For the authentication handler, we create the following file:

_ApiKeyAuthenticationHandler.cs_
```csharp

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);
    }
}
```

| ⚠️ 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:

```csharp
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);
    }
}
```

Now we open up our _Startup.cs_ and extend it like this:

```csharp
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");
            });
    }
}
```

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

```csharp
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!");
    }
}
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ny0fxa9b9ado2r2rh0rf.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nxbudnu26lchcd2tm2wc.png)

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_
```csharp
public class IPAddressPolicyDefaults
{
    public const string LocalhostPolicy = "SourceFromLocalhost";
    public const string LocalhostIP = "::1";
}
```

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

    public IPAddress SourceAddress { get; }
}
```

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

```csharp
public class Startup
{
    // ...

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

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

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_
```csharp
// 👇 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;
    }
}
```

This handler must be registered in the dependency injection container in _startup.cs_:

```csharp
public class Startup
{
    // ...

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

        services.AddSingleton<IAuthorizationHandler, IPAddressAuthorizationHandler>();
    }
}
```

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

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

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/p2tg36oqibw2615ajso1.png)

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! 😊