Add client certificate support (#862)

* Add client certificate support

* Add missing test certificate file

* Review fixes

* Review fixes

* Review fixes

* Review fixes
This commit is contained in:
billybraga
2022-12-11 14:30:47 -05:00
committed by GitHub
parent 9606fee8cb
commit 9ed6a75384
18 changed files with 236 additions and 33 deletions

View File

@@ -96,4 +96,16 @@ public class SettingsModel
/// Default value = "All".
/// </summary>
public QueryParameterMultipleValueSupport? QueryParameterMultipleValueSupport { get; set; }
#if NETSTANDARD1_3_OR_GREATER || NET461
/// <summary>
/// Server client certificate mode
/// </summary>
public ClientCertificateMode ClientCertificateMode { get; set; }
/// <summary>
/// Whether to accept any client certificate
/// </summary>
public bool AcceptAnyClientCertificate { get; set; }
#endif
}

View File

@@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
#if NETSTANDARD1_3_OR_GREATER || NET461
using System.Security.Cryptography.X509Certificates;
#endif
using WireMock.Types;
using WireMock.Util;
@@ -134,4 +137,11 @@ public interface IRequestMessage
/// Gets the origin
/// </summary>
string Origin { get; }
#if NETSTANDARD1_3_OR_GREATER || NET461
/// <summary>
/// Gets the connection's client certificate
/// </summary>
X509Certificate2? ClientCertificate { get; }
#endif
}

View File

@@ -0,0 +1,31 @@
namespace WireMock.Types;
#if NETSTANDARD1_3_OR_GREATER || NET461
/// <summary>
/// Describes the client certificate requirements for a HTTPS connection.
/// This enum is the same as https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.server.kestrel.https.clientcertificatemode
/// </summary>
public enum ClientCertificateMode
{
/// <summary>
/// A client certificate is not required and will not be requested from clients.
/// </summary>
NoCertificate,
/// <summary>
/// A client certificate will be requested; however, authentication will not fail if a certificate is not provided by the client.
/// </summary>
AllowCertificate,
/// <summary>
/// A client certificate will be requested, and the client must provide a valid certificate for authentication to succeed.
/// </summary>
RequireCertificate,
/// <summary>
/// A client certificate is not required and will not be requested from clients at the start of the connection.
/// It may be requested by the application later.
/// </summary>
DelayCertificate,
}
#endif

View File

@@ -4,7 +4,7 @@
<Description>Commonly used models, enumerations and types.</Description>
<AssemblyTitle>WireMock.Net.Abstractions</AssemblyTitle>
<Authors>Stef Heyenrath</Authors>
<TargetFrameworks>net45;net451;netstandard1.0;netstandard2.0;netstandard2.1</TargetFrameworks>
<TargetFrameworks>net45;net451;net461;netstandard1.0;netstandard1.3;netstandard2.0;netstandard2.1</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591;8603</NoWarn>
<AssemblyName>WireMock.Net.Abstractions</AssemblyName>
@@ -41,6 +41,16 @@
</PackageReference>
</ItemGroup>
<ItemGroup Condition="$(TargetFramework.StartsWith('netstandard')) and '$(TargetFramework)' != 'netstandard1.0'">
<PackageReference Include="System.Security.Cryptography.X509Certificates" Version="4.3.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net461'">
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies.net46" Version="1.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<!--<ItemGroup Condition="'$(TargetFramework)' == 'net45'">
<PackageReference Include="Nullable" Version="1.2.1">
<PrivateAssets>all</PrivateAssets>

View File

@@ -2,9 +2,10 @@
using System.Collections.Generic;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using WireMock.HttpsCertificate;
using CertificateLoader = WireMock.HttpsCertificate.CertificateLoader;
namespace WireMock.Owin
{
@@ -26,21 +27,25 @@ namespace WireMock.Owin
{
kestrelOptions.ListenAnyIP(urlDetail.Port, listenOptions =>
{
if (wireMockMiddlewareOptions.CustomCertificateDefined)
listenOptions.UseHttps(options =>
{
listenOptions.UseHttps(CertificateLoader.LoadCertificate(
wireMockMiddlewareOptions.X509StoreName,
wireMockMiddlewareOptions.X509StoreLocation,
wireMockMiddlewareOptions.X509ThumbprintOrSubjectName,
wireMockMiddlewareOptions.X509CertificateFilePath,
wireMockMiddlewareOptions.X509CertificatePassword,
urlDetail.Host)
);
}
else
{
listenOptions.UseHttps();
}
if (wireMockMiddlewareOptions.CustomCertificateDefined)
{
options.ServerCertificate = CertificateLoader.LoadCertificate(
wireMockMiddlewareOptions.X509StoreName,
wireMockMiddlewareOptions.X509StoreLocation,
wireMockMiddlewareOptions.X509ThumbprintOrSubjectName,
wireMockMiddlewareOptions.X509CertificateFilePath,
wireMockMiddlewareOptions.X509CertificatePassword,
urlDetail.Host);
}
options.ClientCertificateMode = (ClientCertificateMode) wireMockMiddlewareOptions.ClientCertificateMode;
if (wireMockMiddlewareOptions.AcceptAnyClientCertificate)
{
options.ClientCertificateValidation = (_, _, _) => true;
}
});
});
}
else

View File

@@ -2,6 +2,7 @@
using System.Collections.Generic;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using WireMock.HttpsCertificate;
@@ -23,21 +24,22 @@ internal partial class AspNetCoreSelfHost
{
if (urlDetail.IsHttps)
{
if (wireMockMiddlewareOptions.CustomCertificateDefined)
options.UseHttps(new HttpsConnectionFilterOptions
{
options.UseHttps(CertificateLoader.LoadCertificate(
wireMockMiddlewareOptions.X509StoreName,
wireMockMiddlewareOptions.X509StoreLocation,
wireMockMiddlewareOptions.X509ThumbprintOrSubjectName,
wireMockMiddlewareOptions.X509CertificateFilePath,
wireMockMiddlewareOptions.X509CertificatePassword,
urlDetail.Host)
);
}
else
{
options.UseHttps(PublicCertificateHelper.GetX509Certificate2());
}
ServerCertificate = wireMockMiddlewareOptions.CustomCertificateDefined
? CertificateLoader.LoadCertificate(
wireMockMiddlewareOptions.X509StoreName,
wireMockMiddlewareOptions.X509StoreLocation,
wireMockMiddlewareOptions.X509ThumbprintOrSubjectName,
wireMockMiddlewareOptions.X509CertificateFilePath,
wireMockMiddlewareOptions.X509CertificatePassword,
urlDetail.Host)
: PublicCertificateHelper.GetX509Certificate2(),
ClientCertificateMode = (ClientCertificateMode) wireMockMiddlewareOptions.ClientCertificateMode,
ClientCertificateValidation = wireMockMiddlewareOptions.AcceptAnyClientCertificate
? (_, _, _) => true
: null,
});
}
}
}

View File

@@ -42,6 +42,10 @@ internal interface IWireMockMiddlewareOptions
Action<IServiceCollection>? AdditionalServiceRegistration { get; set; }
CorsPolicyOptions? CorsPolicyOptions { get; set; }
ClientCertificateMode ClientCertificateMode { get; set; }
bool AcceptAnyClientCertificate { get; set; }
#endif
IFileSystemHandler? FileSystemHandler { get; set; }

View File

@@ -68,7 +68,21 @@ namespace WireMock.Owin.Mappers
body = await BodyParser.ParseAsync(bodyParserSettings).ConfigureAwait(false);
}
return new RequestMessage(options, urlDetails, method, clientIP, body, headers, cookies) { DateTime = DateTime.UtcNow };
return new RequestMessage(
options,
urlDetails,
method,
clientIP,
body,
headers,
cookies
#if USE_ASPNETCORE
, await request.HttpContext.Connection.GetClientCertificateAsync()
#endif
)
{
DateTime = DateTime.UtcNow
};
}
private static (UrlDetails UrlDetails, string ClientIP) ParseRequest(IRequest request)

View File

@@ -42,6 +42,11 @@ internal class WireMockMiddlewareOptions : IWireMockMiddlewareOptions
public Action<IServiceCollection>? AdditionalServiceRegistration { get; set; }
public CorsPolicyOptions? CorsPolicyOptions { get; set; }
public ClientCertificateMode ClientCertificateMode { get; set; }
/// <inheritdoc />
public bool AcceptAnyClientCertificate { get; set; }
#endif
/// <inheritdoc cref="IWireMockMiddlewareOptions.FileSystemHandler"/>

View File

@@ -4,6 +4,9 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
#if USE_ASPNETCORE
using System.Security.Cryptography.X509Certificates;
#endif
using Stef.Validation;
using WireMock.Models;
using WireMock.Owin;
@@ -92,6 +95,11 @@ public class RequestMessage : IRequestMessage
/// <inheritdoc cref="IRequestMessage.Origin" />
public string Origin { get; }
#if USE_ASPNETCORE
/// <inheritdoc cref="IRequestMessage.ClientCertificate" />
public X509Certificate2? ClientCertificate { get; }
#endif
/// <summary>
/// Used for Unit Testing
/// </summary>
@@ -115,13 +123,20 @@ public class RequestMessage : IRequestMessage
/// <param name="bodyData">The BodyData.</param>
/// <param name="headers">The headers.</param>
/// <param name="cookies">The cookies.</param>
#if USE_ASPNETCORE
/// <param name="clientCertificate">The client certificate</param>
#endif
internal RequestMessage(
IWireMockMiddlewareOptions? options,
UrlDetails urlDetails, string method,
string clientIP,
IBodyData? bodyData = null,
IDictionary<string, string[]>? headers = null,
IDictionary<string, string>? cookies = null)
IDictionary<string, string>? cookies = null
#if USE_ASPNETCORE
, X509Certificate2? clientCertificate = null
#endif
)
{
Guard.NotNull(urlDetails, nameof(urlDetails));
Guard.NotNull(method, nameof(method));
@@ -156,6 +171,9 @@ public class RequestMessage : IRequestMessage
Cookies = cookies;
RawQuery = urlDetails.Url.Query;
Query = QueryStringParser.Parse(RawQuery, options?.QueryParameterMultipleValueSupport);
#if USE_ASPNETCORE
ClientCertificate = clientCertificate;
#endif
}
/// <summary>

View File

@@ -226,7 +226,9 @@ public partial class WireMockServer
QueryParameterMultipleValueSupport = _settings.QueryParameterMultipleValueSupport,
#if USE_ASPNETCORE
CorsPolicyOptions = _settings.CorsPolicyOptions?.ToString()
CorsPolicyOptions = _settings.CorsPolicyOptions?.ToString(),
ClientCertificateMode = _settings.ClientCertificateMode,
AcceptAnyClientCertificate = _settings.AcceptAnyClientCertificate
#endif
};
@@ -275,6 +277,9 @@ public partial class WireMockServer
_settings.CorsPolicyOptions = corsPolicyOptions;
_options.CorsPolicyOptions = corsPolicyOptions;
}
_options.ClientCertificateMode = _settings.ClientCertificateMode;
_options.AcceptAnyClientCertificate = _settings.AcceptAnyClientCertificate;
#endif
return ResponseMessageBuilder.Create("Settings updated");

View File

@@ -331,6 +331,8 @@ public partial class WireMockServer : IWireMockServer
#if USE_ASPNETCORE
_options.AdditionalServiceRegistration = _settings.AdditionalServiceRegistration;
_options.CorsPolicyOptions = _settings.CorsPolicyOptions;
_options.ClientCertificateMode = _settings.ClientCertificateMode;
_options.AcceptAnyClientCertificate = _settings.AcceptAnyClientCertificate;
_httpServer = new AspNetCoreSelfHost(_options, urlOptions);
#else

View File

@@ -235,6 +235,19 @@ public class WireMockServerSettings
[PublicAPI]
public bool CustomCertificateDefined => CertificateSettings?.IsDefined == true;
#if USE_ASPNETCORE
/// <summary>
/// Client certificate mode for the server
/// </summary>
[PublicAPI]
public ClientCertificateMode ClientCertificateMode { get; set; }
/// <summary>
/// Whether to accept any client certificate
/// </summary>
public bool AcceptAnyClientCertificate { get; set; }
#endif
/// <summary>
/// Defines the global IWebhookSettings to use.
/// </summary>

View File

@@ -64,6 +64,8 @@ public static class WireMockServerSettingsParser
#if USE_ASPNETCORE
settings.CorsPolicyOptions = parser.GetEnumValue(nameof(WireMockServerSettings.CorsPolicyOptions), CorsPolicyOptions.None);
settings.ClientCertificateMode = parser.GetEnumValue(nameof(WireMockServerSettings.ClientCertificateMode), ClientCertificateMode.NoCertificate);
settings.AcceptAnyClientCertificate = parser.GetBoolValue(nameof(WireMockServerSettings.AcceptAnyClientCertificate));
#endif
var loggerType = parser.GetStringValue("WireMockLogger");

View File

@@ -0,0 +1,7 @@
## Creating a client certificate like client_cert.pfx
Follow the instructions to [create a root certificate](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/certauth?view=aspnetcore-7.0#create-root-ca),
then [trust it](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/certauth?view=aspnetcore-7.0#install-in-the-trusted-root)
and [create a child certificate from it](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/certauth?view=aspnetcore-7.0#create-child-certificate-from-root-certificate).
Since the root certificate of `client_cert.pfx` is obviously not trusted automatically by cloning this repo, the tests in `WireMockServerTests.ClientCertificate.cs` set `WireMockServerSettings.AcceptAnyClientCertificate` to `true` so that tests pass even if the device hasn't trusted the root of `client_cert.pfx`.

View File

@@ -96,6 +96,10 @@
<None Update="__admin\mappings\subdirectory\*.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="client_cert.pfx">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<DependentUpon>WireMockServerTests.ClientCertificate.cs</DependentUpon>
</None>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,59 @@
#if !NET451 && !NET452
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using FluentAssertions;
using System.Security.Cryptography.X509Certificates;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using WireMock.Settings;
using WireMock.Types;
using Xunit;
namespace WireMock.Net.Tests;
public partial class WireMockServerTests
{
[Fact]
public async Task WireMockServer_WithRequiredClientCertificates_Should_Work_Correct()
{
// Arrange
var settings = new WireMockServerSettings
{
ClientCertificateMode = ClientCertificateMode.RequireCertificate,
AcceptAnyClientCertificate = true,
UseSSL = true,
};
using var server = WireMockServer.Start(settings);
server.Given(Request.Create().WithPath("/*"))
.RespondWith(Response.Create().WithCallback(message => new ResponseMessage
{
StatusCode = message.ClientCertificate?.Thumbprint == "2E32E3528C87046A95B8B0BA172A1597C3AF3A9D"
? 200
: 403
}));
var certificates = new X509Certificate2Collection();
certificates.Import("client_cert.pfx", "1234", X509KeyStorageFlags.Exportable);
var httpMessageHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true,
};
httpMessageHandler.ClientCertificates.AddRange(certificates);
// Act
var response = await new HttpClient(httpMessageHandler)
.GetAsync("https://localhost:" + server.Ports[0] + "/foo")
.ConfigureAwait(false);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
#endif

Binary file not shown.