Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FEAT: add certificate based authentication filter & attribute #43

Merged
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
ec10d11
FEAT: add certificate based authentication filter & attribute
May 26, 2019
d8bed45
TEST: certificate issuername and combined validation
Jun 12, 2019
0597836
PR-ADD: use private class for the client certificate configuration
Jun 12, 2019
273a2d5
TEST: correct subject/issuer name generation for self-signed certificate
Jun 12, 2019
0db51cd
TEST: global certificate based authentication
Jun 13, 2019
0fe9818
PR-SUG: extract inner-class in TestApiServer to configure the TLS cli…
Jun 13, 2019
a3bf894
PR-SUG: rename 'X509Validation' to 'X509ValidationRequirement'
Jun 13, 2019
891a108
PR-STYLE: update the certificate authentication filter to pass-along …
Jun 19, 2019
f14a3a6
PR-TEST: certificate authentication with thumbprint testing
Jun 19, 2019
62df27b
PR-SUG: add logging via 'ILoggerFactory' given via HttpContext
Jun 19, 2019
194fccc
PR-SUG: use configuration keys i.o. actual values for setting certifi…
Jun 19, 2019
d86681b
PR-SUG: use 'ISecretProvider' as basis for configurable certificate …
Jun 20, 2019
ced8ec1
PR-DOC: add docs for certificate authentication
Jun 20, 2019
81021ff
PR-DEL: remove the configuration keys addition in the TestApiServer
Jun 20, 2019
f112af3
PR-FIX: alter the guard predicate & message for the configuration keys
Jun 20, 2019
aa67caf
Update docs/features/certificate-authentication.md
stijnmoreels Jun 21, 2019
4090702
PR-DOC: move authentication mechanisems to '/features/auth'
Jun 21, 2019
16af314
Merge branch 'feature/certificate-authentication' of https://github.c…
Jun 21, 2019
68defc0
PR-SUG: rename 'IsAllowedCertificate' > 'IsCertificateAllowed'
Jun 21, 2019
0f7eca4
PR-SUG: provide way to configure each requirement via diff validation…
Jun 25, 2019
5d5d7d9
PR-DOC: update docs with new certificate validation location setup
Jun 25, 2019
c79478f
PR-DOC: add line break in introduction text
Jul 3, 2019
4b8c2c6
Merge remote-tracking branch 'upstream/master' into feature/certifica…
Jul 3, 2019
dda914b
PR-DOC: remove '-authentication' of certificate auth. doc
Jul 3, 2019
8dd83b6
PR-SUG: restructure private methods in order to have less arguments
Jul 3, 2019
b841862
PR-SUG: extract 'GetLoggerOrDefault' in authentication filter
Jul 3, 2019
36bc344
PR-SUG: extract 'GetLoggerOrDefault' in authentication filter
Jul 3, 2019
b638cf0
PR-FIX: guard against miss-implementations of certificate locations
Jul 3, 2019
c99223d
PR-FIX: guard against miss-client.certificates
Jul 3, 2019
739ad53
PR-DOC: rename and document more the members with XML docs
Jul 3, 2019
04deb5b
PR-FIX: rename with 'ExpectedValue' in location signature
Jul 3, 2019
de9c48a
PR-SUG: use dedicated config object to set and retrieve the expected …
Jul 3, 2019
3ca9d91
PR-ADD: reconsider guards on every public/internal member
Jul 3, 2019
476b21d
PR-DOC: update certificate authentication docs with new authenticatio…
Jul 3, 2019
efeafbd
PR-DEL: remove vscode cache
Jul 4, 2019
69cd88f
PR-FIX: make validation requirement internal
Jul 4, 2019
4e87799
PR-SUG: use 'if' statement instead of expression-based null-check
Jul 4, 2019
61aedc0
PR-SUG: extract lamdba function that switches between all certificate…
Jul 8, 2019
26edbe9
PR-SUG: introduce textbook builder pattern
Jul 8, 2019
d3d8d88
PR-ADD: add remarks about registration of certificate validator
Jul 8, 2019
96b01e4
PR-ADD: extra guard against invalid values in the validation location
Jul 9, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions docs/features/certificate-authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved
title: "Authentication with certificate via ASP.NET Core authentication filters"
layout: default
---

The `Arcus.WebApi.Security` package provides a mechanism that uses the client certificate of the request to grant access to a web application.
This authentication process consists of following parts:

1. Find the client certificate configured on the HTTP request
2. Determine which properties of the received client certificate are used for authentication
3. The property value(s) of the client certificate matches the value(s) determined via configured secret provider

The package allows two ways to configure this type of authentication mechanism in an <span>ASP.NET</span> application:
- [Globally enforce certificate authentication](#Globally-enforce-certificate-authentication)
- [Enforce certificate authentication per controller or operation](#Enforce-certificate-authentication-per-controller-or-operation)

## Globally enforce certificate authentication

### Introduction

The `CertificateAuthenticationFilter` can be added to the request filters in an <span>ASP.NET</span> Core application.
This filter will then add authentication to all endpoints via one or many certificate properties configurable on the filter itself.

### Usage

The authentication requires an `ICachedSecretProvider` or `ISecretProvider` dependency to be registered with the services container of the <span>ASP.NET</span> request pipeline. This is typically done in the `ConfigureServices` method of the `Startup` class.
Once this is done, the `CertificateAuthenticationFilter` can be added to the filters that will be applied to all actions:

```csharp
public void ConfigureServices(IServiceCollections services)
{
services.AddScoped<ICachedSecretProvider(serviceProvider => new MyCachedSecretProvider());
services.AddMvc(
options => options.Filters.Add(
new CertificateAuthenticationFilter(
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved
X509CertificateRequirement.SubjectName,
"key-to-certificate-subject-name"
)));
}
```

## Enforce certificate authentication per controller or operation

### Introduction

The `CertificateAuthenticationAttribute` can be added on both controller- and operation level in an <span>ASP.NET</span> Core application.
This certificate authentication will then be applied to the endpoint(s) that are decorated with the `CertificateAuthenticationAttribute`.

### Usage

The authentication requires an `ICachedSecretProvider` or `ISecretProvider` dependency to be registered with the services container of the <span>ASP.NET</span> request pipeline. This is typically done in the `ConfigureServices` method of the `Startup` class:

```csharp
public void ConfigureServices(IServiceCollections services)
{
services.AddScoped<ICachedSecretProvider>(serviceProvider => new CachedSecretProvider(new MySecretProvider()));
services.AddMvc();
}
```

After that, the `CertificateAuthenticationAttribute` attribute can be applied on the controllers, or if more fine-grained control is needed, on the operations that requires authentication:

```csharp
[ApiController]
[CertificateAuthentication(X509CertificateRequirement.IssuerName, "key-to-certificate-issuer-name")]
public class MyApiController : ControllerBase
{
[HttpGet]
[Route("authz/certificate)]
stijnmoreels marked this conversation as resolved.
Show resolved Hide resolved
public Task<IActionResult> AuthorizedGet()
{
return Task.FromResult<IActionResult>(Ok());
}
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using System.Security.Cryptography.X509Certificates;
using GuardNet;
using Microsoft.AspNetCore.Mvc;

namespace Arcus.WebApi.Security.Authentication
{
/// <summary>
/// Authentication filter to secure HTTP requests by allowing only certain values in the client certificate.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)]
public class CertificateAuthenticationAttribute : TypeFilterAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="CertificateAuthenticationAttribute"/> class.
/// </summary>
/// <param name="requirement">The property of the client <see cref="X509Certificate2"/> to validate.</param>
/// <param name="expectedValue">The expected value the property of the <see cref="X509Certificate2"/> should have.</param>
public CertificateAuthenticationAttribute(X509ValidationRequirement requirement, string expectedValue) : base(typeof(CertificateAuthenticationFilter))
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved
{
Guard.NotNullOrWhitespace(expectedValue, nameof(expectedValue), "Expected value in certificate cannot be blank");

Arguments = new object[] { requirement, expectedValue };
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using Arcus.Security.Secrets.Core.Interfaces;
using GuardNet;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace Arcus.WebApi.Security.Authentication
{
/// <summary>
/// Authentication filter to secure HTTP requests by allowing only certain values in the client certificate.
/// </summary>
public class CertificateAuthenticationFilter : IAsyncAuthorizationFilter
{
private readonly (X509ValidationRequirement requirement, string configurationKey)[] _requirements;
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved

/// <summary>
/// Initializes a new instance of the <see cref="CertificateAuthenticationFilter"/> class.
/// </summary>
/// <param name="requirement">The property of the client <see cref="X509Certificate2"/> to validate.</param>
/// <param name="expectedValue">The expected value the property of the <see cref="X509Certificate2"/> should have.</param>
public CertificateAuthenticationFilter(X509ValidationRequirement requirement, string expectedValue)
: this((requirement, expectedValue)) { }

/// <summary>
/// Initializes a new instance of the <see cref="CertificateAuthenticationFilter"/> class.
/// </summary>
/// <param name="requirements">The sequence of requirement property of the client <see cref="X509Certificate2"/> and expected values it should have.</param>
public CertificateAuthenticationFilter(
params (X509ValidationRequirement requirement, string configurationKey)[] requirements)
{
Guard.NotNull(requirements, nameof(requirements), "Sequence of requirements and their expected values should not be 'null'");
Guard.For<ArgumentException>(() => requirements.Any(requirement => String.IsNullOrWhiteSpace(requirement.configurationKey)), "Sequence of requirements cannot contain any configuration key that is blank");

_requirements = requirements;
}

/// <summary>
/// Called early in the filter pipeline to confirm request is authorized.
/// </summary>
/// <param name="context">The <see cref="T:Microsoft.AspNetCore.Mvc.Filters.AuthorizationFilterContext" />.</param>
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved
Guard.NotNull(context, nameof(context));
Guard.NotNull(context.HttpContext, nameof(context.HttpContext));
Guard.For<ArgumentException>(() => context.HttpContext.Connection is null, "Invalid action context given without any HTTP connection");
Guard.For<ArgumentException>(() => context.HttpContext.RequestServices is null, "Invalid action context given without any HTTP request services");

ILogger logger =
context.HttpContext.RequestServices
.GetService<ILoggerFactory>()
?.CreateLogger<CertificateAuthenticationFilter>()
?? (ILogger) NullLogger.Instance;

ISecretProvider userDefinedSecretProvider =
context.HttpContext.RequestServices.GetService<ICachedSecretProvider>()
?? context.HttpContext.RequestServices.GetService<ISecretProvider>();

if (userDefinedSecretProvider == null)
{
throw new KeyNotFoundException(
$"No configured {nameof(ICachedSecretProvider)} or {nameof(ISecretProvider)} implementation found in the request service container. "
+ "Please configure such an implementation (ex. in the Startup) of your application");
}

X509Certificate2 clientCertificate = context.HttpContext.Connection.ClientCertificate;
if (clientCertificate == null)
{
logger.LogWarning(
"No client certificate was specified in the HTTP request while this authentication filter "
+ $"requires a certificate to validate on the {String.Join(", ", _requirements.Select(item => item.requirement))}");

context.Result = new UnauthorizedResult();
}
else if (!await IsAllowedCertificate(clientCertificate, userDefinedSecretProvider, logger))
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved
{
context.Result = new UnauthorizedResult();
}
}

private async Task<bool> IsAllowedCertificate(
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved
X509Certificate2 clientCertificate,
ISecretProvider provider,
ILogger logger)
{
var requirementValues = await Task.WhenAll(_requirements.Select(async item =>
{
string expected = await provider.Get(item.configurationKey);
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved
return (requirement: item.requirement, key: item.configurationKey, expected: expected);
}));

return requirementValues.All(value =>
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved
{
if (value.expected == null)
{
logger.LogWarning($"Client certificate authentication failed: no configuration value found for key={value.key}");
return false;
}

switch (value.requirement)
{
case X509ValidationRequirement.SubjectName:
return IsAllowedCertificateSubject(clientCertificate, value.expected, logger);
case X509ValidationRequirement.IssuerName:
return IsAllowedCertificateIssuer(clientCertificate, value.expected, logger);
case X509ValidationRequirement.Thumbprint:
return IsAllowedCertificateThumbprint(clientCertificate, value.expected, logger);
default:
throw new ArgumentOutOfRangeException(nameof(value.requirement), value.requirement, "Unknown validation type specified");
}
});
}

private static bool IsAllowedCertificateSubject(X509Certificate2 clientCertificate, string expected, ILogger logger)
{
IEnumerable<string> certificateSubjectNames =
clientCertificate.Subject
.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(subject => subject.Trim());

bool isAllowed = certificateSubjectNames.Any(subject => String.Equals(subject, expected));
if (!isAllowed)
{
logger.LogWarning(
"Client certificate authentication failed on subject: "
+ $"no subject found (actual={String.Join(", ", certificateSubjectNames)}) in certificate that matches expected={expected}");
}

return isAllowed;
}

private static bool IsAllowedCertificateIssuer(X509Certificate2 clientCertificate, string expected, ILogger logger)
{
IEnumerable<string> issuerNames =
clientCertificate.Issuer
.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(issuer => issuer.Trim());

bool isAllowed = issuerNames.Any(issuer => String.Equals(issuer, expected));
if (!isAllowed)
{
logger.LogWarning(
"Client certificate authentication failed on issuer: "
+ $"no issuer found (actual={String.Join(", ", issuerNames)}) in certificate that matches expected={expected}");
}

return isAllowed;
}

private static bool IsAllowedCertificateThumbprint(X509Certificate2 clientCertificate, string expected, ILogger logger)
{
string actual = clientCertificate.Thumbprint?.Trim();

bool isAllowed = String.Equals(expected, actual);
if (!isAllowed)
{
logger.LogWarning(
"Client certificate authentication failed on thumbprint: "
+ $"expected={expected} <> actual={actual}");
}

return isAllowed;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Security.Cryptography.X509Certificates;

namespace Arcus.WebApi.Security.Authentication
{
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved
/// <summary>
/// Represents which value of the client <see cref="X509Certificate2"/> should be validated in the <see cref="CertificateAuthenticationFilter"/>.
/// </summary>
public enum X509ValidationRequirement
{
/// <summary>
/// Allow only certificates where the <see cref="X509Certificate.Subject"/> matches.
/// </summary>
SubjectName,

/// <summary>
/// Allow only certificates where the <see cref="X509Certificate.Issuer"/> matches.
/// </summary>
IssuerName,

/// <summary>
/// Allow only certificates where the <see cref="X509Certificate2.Thumbprint"/> matches.
/// </summary>
Thumbprint
}
}
2 changes: 2 additions & 0 deletions src/Arcus.WebApi.Unit/Arcus.WebApi.Unit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle" Version="1.8.5" />
<PackageReference Include="BouncyCastle.NetCoreSdk" Version="1.9.0.1" />
<PackageReference Include="System.Web.Http" Version="4.0.0" />
<PackageReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
Expand Down
41 changes: 41 additions & 0 deletions src/Arcus.WebApi.Unit/Hosting/CertificateConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using System.Security.Cryptography.X509Certificates;
using GuardNet;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;

namespace Arcus.WebApi.Unit.Hosting
{
/// <summary>
/// Security configuration addition to set the TLS client certificate on every call made via the <see cref="TestApiServer"/>.
/// </summary>
internal class CertificateConfiguration : IStartupFilter
{
private readonly X509Certificate2 _clientCertificate;

/// <summary>
/// Initializes a new instance of the <see cref="CertificateConfiguration"/> class.
/// </summary>
/// <param name="clientCertificate">The client certificate.</param>
public CertificateConfiguration(X509Certificate2 clientCertificate)
{
Guard.NotNull(clientCertificate, nameof(clientCertificate));

_clientCertificate = clientCertificate;
}

/// <inheritdoc />
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return builder =>
{
builder.Use((context, nxt) =>
{
context.Connection.ClientCertificate = _clientCertificate;
return nxt();
});
next(builder);
};
}
}
}
Loading