Add support for AzureAD authentication for REST admin interface (#637)

This commit is contained in:
Stef Heyenrath
2021-10-20 16:39:51 +02:00
committed by GitHub
parent 48b3e7a305
commit affe388e30
16 changed files with 230 additions and 45 deletions

View File

@@ -4,7 +4,7 @@
</PropertyGroup>
<PropertyGroup>
<VersionPrefix>1.4.23</VersionPrefix>
<VersionPrefix>1.4.24</VersionPrefix>
<PackageReleaseNotes>See CHANGELOG.md</PackageReleaseNotes>
<PackageIcon>WireMock.Net-Logo.png</PackageIcon>
<PackageProjectUrl>https://github.com/WireMock-Net/WireMock.Net</PackageProjectUrl>

View File

@@ -77,9 +77,17 @@ namespace WireMock.Net.ConsoleApplication
System.Console.WriteLine("WireMockServer listening at {0}", string.Join(",", server.Urls));
server.SetBasicAuthentication("a", "b");
//server.SetAzureADAuthentication("6c2a4722-f3b9-4970-b8fc-fac41e29stef", "8587fde1-7824-42c7-8592-faf92b04stef");
// server.AllowPartialMapping();
server.Given(Request.Create().WithPath("/mypath").UsingPost())
.RespondWith(Response.Create()
.WithHeader("Content-Type", "application/json")
.WithBodyAsJson("{{JsonPath.SelectToken request.body \"..name\"}}")
.WithTransformer()
);
server
.Given(Request.Create().WithPath(p => p.Contains("x")).UsingGet())
.AtPriority(4)

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using JetBrains.Annotations;
@@ -104,9 +104,9 @@ namespace WireMock.Server
void ReadStaticMappings([CanBeNull] string folder = null);
/// <summary>
/// Removes the basic authentication.
/// Removes the authentication.
/// </summary>
void RemoveBasicAuthentication();
void RemoveAuthentication();
/// <summary>
/// Resets LogEntries and Mappings.
@@ -134,6 +134,13 @@ namespace WireMock.Server
/// <param name="folder">The optional folder. If not defined, use {CurrentFolder}/__admin/mappings</param>
void SaveStaticMappings([CanBeNull] string folder = null);
/// <summary>
/// Sets the basic authentication.
/// </summary>
/// <param name="tenant">The Tenant.</param>
/// <param name="audience">The Audience or Resource.</param>
void SetAzureADAuthentication([NotNull] string tenant, [NotNull] string audience);
/// <summary>
/// Sets the basic authentication.
/// </summary>

View File

@@ -0,0 +1,71 @@
#if !NETSTANDARD1_3
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Text.RegularExpressions;
using AnyOfTypes;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using WireMock.Matchers;
using WireMock.Models;
namespace WireMock.Authentication
{
/// <summary>
/// https://www.c-sharpcorner.com/article/how-to-validate-azure-ad-token-using-console-application/
/// https://stackoverflow.com/questions/38684865/validation-of-an-azure-ad-bearer-token-in-a-console-application
/// </summary>
internal class AzureADAuthenticationMatcher : IStringMatcher
{
private const string BearerPrefix = "Bearer ";
private readonly string _audience;
private readonly string _stsDiscoveryEndpoint;
public AzureADAuthenticationMatcher(string tenant, string audience)
{
_audience = audience;
_stsDiscoveryEndpoint = string.Format(CultureInfo.InvariantCulture, "https://login.microsoftonline.com/{0}/.well-known/openid-configuration", tenant);
}
public string Name => nameof(AzureADAuthenticationMatcher);
public MatchBehaviour MatchBehaviour => MatchBehaviour.AcceptOnMatch;
public bool ThrowException => false;
public AnyOf<string, StringPattern>[] GetPatterns()
{
return new AnyOf<string, StringPattern>[0];
}
public double IsMatch(string input)
{
var token = Regex.Replace(input, BearerPrefix, string.Empty, RegexOptions.IgnoreCase);
try
{
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(_stsDiscoveryEndpoint, new OpenIdConnectConfigurationRetriever());
var config = configManager.GetConfigurationAsync().GetAwaiter().GetResult();
var validationParameters = new TokenValidationParameters
{
ValidAudience = _audience,
ValidIssuer = config.Issuer,
IssuerSigningKeys = config.SigningKeys,
ValidateLifetime = true
};
// Throws an Exception as the token is invalid (expired, invalid-formatted, etc.)
new JwtSecurityTokenHandler().ValidateToken(token, validationParameters, out var _);
return MatchScores.Perfect;
}
catch
{
return MatchScores.Mismatch;
}
}
}
}
#endif

View File

@@ -0,0 +1,20 @@
using System;
using System.Text;
using WireMock.Matchers;
namespace WireMock.Authentication
{
internal class BasicAuthenticationMatcher : RegexMatcher
{
public BasicAuthenticationMatcher(string username, string password) : base(BuildPattern(username, password))
{
}
public override string Name => nameof(BasicAuthenticationMatcher);
private static string BuildPattern(string username, string password)
{
return "^(?i)BASIC " + Convert.ToBase64String(Encoding.GetEncoding("ISO-8859-1").GetBytes(username + ":" + password)) + "$";
}
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Concurrent;
using WireMock.Handlers;
using WireMock.Logging;
@@ -19,7 +19,7 @@ namespace WireMock.Owin
TimeSpan? RequestProcessingDelay { get; set; }
IStringMatcher AuthorizationMatcher { get; set; }
IStringMatcher AuthenticationMatcher { get; set; }
bool? AllowPartialMapping { get; set; }

View File

@@ -113,10 +113,10 @@ namespace WireMock.Owin
logRequest = targetMapping.LogMapping;
if (targetMapping.IsAdminInterface && _options.AuthorizationMatcher != null)
if (targetMapping.IsAdminInterface && _options.AuthenticationMatcher != null)
{
bool present = request.Headers.TryGetValue(HttpKnownHeaderNames.Authorization, out WireMockList<string> authorization);
if (!present || _options.AuthorizationMatcher.IsMatch(authorization.ToString()) < MatchScores.Perfect)
if (!present || _options.AuthenticationMatcher.IsMatch(authorization.ToString()) < MatchScores.Perfect)
{
_options.Logger.Error("HttpStatusCode set to 401");
response = ResponseMessageBuilder.Create(null, 401);

View File

@@ -19,7 +19,7 @@ namespace WireMock.Owin
public TimeSpan? RequestProcessingDelay { get; set; }
public IStringMatcher AuthorizationMatcher { get; set; }
public IStringMatcher AuthenticationMatcher { get; set; }
public bool? AllowPartialMapping { get; set; }

View File

@@ -9,6 +9,7 @@ using System.Threading;
using JetBrains.Annotations;
using Newtonsoft.Json;
using WireMock.Admin.Mappings;
using WireMock.Authentication;
using WireMock.Exceptions;
using WireMock.Handlers;
using WireMock.Logging;
@@ -302,6 +303,11 @@ namespace WireMock.Server
SetBasicAuthentication(settings.AdminUsername, settings.AdminPassword);
}
if (!string.IsNullOrEmpty(settings.AdminAzureADTenant) && !string.IsNullOrEmpty(settings.AdminAzureADAudience))
{
SetAzureADAuthentication(settings.AdminAzureADTenant, settings.AdminAzureADAudience);
}
InitAdmin();
}
@@ -404,22 +410,35 @@ namespace WireMock.Server
_options.AllowPartialMapping = allow;
}
/// <inheritdoc cref="IWireMockServer.SetBasicAuthentication" />
/// <inheritdoc cref="IWireMockServer.SetAzureADAuthentication(string, string)" />
[PublicAPI]
public void SetAzureADAuthentication([NotNull] string tenant, [NotNull] string audience)
{
Check.NotNull(tenant, nameof(tenant));
Check.NotNull(audience, nameof(audience));
#if NETSTANDARD1_3
throw new NotSupportedException("AzureADAuthentication is not supported for NETStandard 1.3");
#else
_options.AuthenticationMatcher = new AzureADAuthenticationMatcher(tenant, audience);
#endif
}
/// <inheritdoc cref="IWireMockServer.SetBasicAuthentication(string, string)" />
[PublicAPI]
public void SetBasicAuthentication([NotNull] string username, [NotNull] string password)
{
Check.NotNull(username, nameof(username));
Check.NotNull(password, nameof(password));
string authorization = Convert.ToBase64String(Encoding.GetEncoding("ISO-8859-1").GetBytes(username + ":" + password));
_options.AuthorizationMatcher = new RegexMatcher(MatchBehaviour.AcceptOnMatch, "^(?i)BASIC " + authorization + "$");
_options.AuthenticationMatcher = new BasicAuthenticationMatcher(username, password);
}
/// <inheritdoc cref="IWireMockServer.RemoveBasicAuthentication" />
/// <inheritdoc cref="IWireMockServer.RemoveAuthentication" />
[PublicAPI]
public void RemoveBasicAuthentication()
public void RemoveAuthentication()
{
_options.AuthorizationMatcher = null;
_options.AuthenticationMatcher = null;
}
/// <inheritdoc cref="IWireMockServer.SetMaxRequestLogCount" />

View File

@@ -1,4 +1,4 @@
using System;
using System;
using HandlebarsDotNet;
using JetBrains.Annotations;
using WireMock.Handlers;
@@ -88,6 +88,18 @@ namespace WireMock.Settings
[PublicAPI]
string AdminPassword { get; set; }
/// <summary>
/// The AzureAD Tenant needed for __admin access.
/// </summary>
[PublicAPI]
string AdminAzureADTenant { get; set; }
/// <summary>
/// The AzureAD Audience / Resource for __admin access.
/// </summary>
[PublicAPI]
string AdminAzureADAudience { get; set; }
/// <summary>
/// The RequestLog expiration in hours (optional).
/// </summary>

View File

@@ -1,4 +1,4 @@
using System;
using System;
using HandlebarsDotNet;
using JetBrains.Annotations;
using Newtonsoft.Json;
@@ -64,6 +64,14 @@ namespace WireMock.Settings
[PublicAPI]
public string AdminPassword { get; set; }
/// <inheritdoc cref="IWireMockServerSettings.AdminAzureADTenant"/>
[PublicAPI]
public string AdminAzureADTenant { get; set; }
/// <inheritdoc cref="IWireMockServerSettings.AdminAzureADAudience"/>
[PublicAPI]
public string AdminAzureADAudience { get; set; }
/// <inheritdoc cref="IWireMockServerSettings.RequestLogExpirationDuration"/>
[PublicAPI]
public int? RequestLogExpirationDuration { get; set; }

View File

@@ -1,4 +1,4 @@
using JetBrains.Annotations;
using JetBrains.Annotations;
using WireMock.Logging;
using WireMock.Validation;
@@ -39,6 +39,8 @@ namespace WireMock.Settings
WatchStaticMappingsInSubdirectories = parser.GetBoolValue("WatchStaticMappingsInSubdirectories"),
AdminUsername = parser.GetStringValue("AdminUsername"),
AdminPassword = parser.GetStringValue("AdminPassword"),
AdminAzureADTenant = parser.GetStringValue(nameof(IWireMockServerSettings.AdminAzureADTenant)),
AdminAzureADAudience = parser.GetStringValue(nameof(IWireMockServerSettings.AdminAzureADAudience)),
MaxRequestLogCount = parser.GetIntValue("MaxRequestLogCount"),
RequestLogExpirationDuration = parser.GetIntValue("RequestLogExpirationDuration"),
AllowCSharpCodeMatcher = parser.GetBoolValue("AllowCSharpCodeMatcher"),

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>Lightweight Http Mocking Server for .Net, inspired by WireMock from the Java landscape.</Description>
<AssemblyTitle>WireMock.Net</AssemblyTitle>
@@ -50,16 +50,17 @@
<DefineConstants>USE_ASPNETCORE;NET46</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" Version="2020.1.0" PrivateAssets="All" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
<PackageReference Include="SimMetrics.Net" Version="1.0.5" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.0.12" />
<PackageReference Include="RandomDataGenerator.Net" Version="1.0.12" />
<PackageReference Include="JmesPath.Net" Version="1.0.125" />
<PackageReference Include="AnyOf" Version="0.2.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" Version="2020.1.0" PrivateAssets="All" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
<PackageReference Include="SimMetrics.Net" Version="1.0.5" />
<!--<PackageReference Include="Stef.Validation" Version="0.0.3" />-->
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.0.12" />
<PackageReference Include="RandomDataGenerator.Net" Version="1.0.12" />
<PackageReference Include="JmesPath.Net" Version="1.0.125" />
<PackageReference Include="AnyOf" Version="0.2.0" />
</ItemGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug - Sonar'">
<PackageReference Include="SonarAnalyzer.CSharp" Version="7.8.0.7320">
@@ -68,9 +69,10 @@
</PackageReference>
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' != 'netstandard1.3' ">
<PackageReference Include="XPath2.Extensions" Version="1.1.0" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' != 'netstandard1.3' ">
<PackageReference Include="XPath2.Extensions" Version="1.1.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="6.12.2" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'net451' or '$(TargetFramework)' == 'net452' ">
<!-- Required for WebRequestHandler -->

View File

@@ -96,7 +96,7 @@ namespace WireMock.Net.Tests.Owin
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1", null, new Dictionary<string, string[]>());
_requestMapperMock.Setup(m => m.MapAsync(It.IsAny<IRequest>(), It.IsAny<IWireMockMiddlewareOptions>())).ReturnsAsync(request);
_optionsMock.SetupGet(o => o.AuthorizationMatcher).Returns(new ExactMatcher());
_optionsMock.SetupGet(o => o.AuthenticationMatcher).Returns(new ExactMatcher());
_mappingMock.SetupGet(m => m.IsAdminInterface).Returns(true);
var result = new MappingMatcherResult { Mapping = _mappingMock.Object };
@@ -119,7 +119,7 @@ namespace WireMock.Net.Tests.Owin
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1", null, new Dictionary<string, string[]> { { "h", new[] { "x" } } });
_requestMapperMock.Setup(m => m.MapAsync(It.IsAny<IRequest>(), It.IsAny<IWireMockMiddlewareOptions>())).ReturnsAsync(request);
_optionsMock.SetupGet(o => o.AuthorizationMatcher).Returns(new ExactMatcher());
_optionsMock.SetupGet(o => o.AuthenticationMatcher).Returns(new ExactMatcher());
_mappingMock.SetupGet(m => m.IsAdminInterface).Returns(true);
var result = new MappingMatcherResult { Mapping = _mappingMock.Object };
@@ -152,7 +152,7 @@ namespace WireMock.Net.Tests.Owin
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1", null, new Dictionary<string, string[]>());
_requestMapperMock.Setup(m => m.MapAsync(It.IsAny<IRequest>(), It.IsAny<IWireMockMiddlewareOptions>())).ReturnsAsync(request);
_optionsMock.SetupGet(o => o.AuthorizationMatcher).Returns(new ExactMatcher());
_optionsMock.SetupGet(o => o.AuthenticationMatcher).Returns(new ExactMatcher());
var fileSystemHandlerMock = new Mock<IFileSystemHandler>();
fileSystemHandlerMock.Setup(f => f.GetMappingFolder()).Returns("m");
@@ -201,7 +201,7 @@ namespace WireMock.Net.Tests.Owin
var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1", null, new Dictionary<string, string[]>());
_requestMapperMock.Setup(m => m.MapAsync(It.IsAny<IRequest>(), It.IsAny<IWireMockMiddlewareOptions>())).ReturnsAsync(request);
_optionsMock.SetupGet(o => o.AuthorizationMatcher).Returns(new ExactMatcher());
_optionsMock.SetupGet(o => o.AuthenticationMatcher).Returns(new ExactMatcher());
var fileSystemHandlerMock = new Mock<IFileSystemHandler>();
fileSystemHandlerMock.Setup(f => f.GetMappingFolder()).Returns("m");

View File

@@ -1,3 +1,4 @@
using FluentAssertions;
using NFluent;
using WireMock.Matchers;
using WireMock.Owin;
@@ -19,26 +20,43 @@ namespace WireMock.Net.Tests
// Assert
var options = server.GetPrivateFieldValue<IWireMockMiddlewareOptions>("_options");
Check.That(options.AuthorizationMatcher.Name).IsEqualTo("RegexMatcher");
Check.That(options.AuthorizationMatcher.MatchBehaviour).IsEqualTo(MatchBehaviour.AcceptOnMatch);
Check.That(options.AuthorizationMatcher.GetPatterns()).ContainsExactly("^(?i)BASIC eDp5$");
Check.That(options.AuthenticationMatcher.Name).IsEqualTo("BasicAuthenticationMatcher");
Check.That(options.AuthenticationMatcher.MatchBehaviour).IsEqualTo(MatchBehaviour.AcceptOnMatch);
Check.That(options.AuthenticationMatcher.GetPatterns()).ContainsExactly("^(?i)BASIC eDp5$");
server.Stop();
}
[Fact]
public void WireMockServer_Authentication_RemoveBasicAuthentication()
public void WireMockServer_Authentication_SetSetAzureADAuthentication()
{
// Assign
var server = WireMockServer.Start();
// Act
server.SetAzureADAuthentication("x", "y");
// Assert
var options = server.GetPrivateFieldValue<IWireMockMiddlewareOptions>("_options");
options.AuthenticationMatcher.Name.Should().Be("AzureADAuthenticationMatcher");
options.AuthenticationMatcher.MatchBehaviour.Should().Be(MatchBehaviour.AcceptOnMatch);
server.Stop();
}
[Fact]
public void WireMockServer_Authentication_RemoveAuthentication()
{
// Assign
var server = WireMockServer.Start();
server.SetBasicAuthentication("x", "y");
// Act
server.RemoveBasicAuthentication();
server.RemoveAuthentication();
// Assert
var options = server.GetPrivateFieldValue<IWireMockMiddlewareOptions>("_options");
Check.That(options.AuthorizationMatcher).IsNull();
Check.That(options.AuthenticationMatcher).IsNull();
server.Stop();
}

View File

@@ -1,6 +1,8 @@
using System.Linq;
using FluentAssertions;
using Moq;
using NFluent;
using System.Linq;
using WireMock.Authentication;
using WireMock.Logging;
using WireMock.Owin;
using WireMock.Server;
@@ -32,7 +34,23 @@ namespace WireMock.Net.Tests
// Assert
var options = server.GetPrivateFieldValue<IWireMockMiddlewareOptions>("_options");
Check.That(options.AuthorizationMatcher).IsNotNull();
options.AuthenticationMatcher.Should().NotBeNull().And.BeOfType<BasicAuthenticationMatcher>();
}
[Fact]
public void WireMockServer_WireMockServerSettings_StartAdminInterfaceTrue_AzureADAuthenticationIsSet()
{
// Assign and Act
var server = WireMockServer.Start(new WireMockServerSettings
{
StartAdminInterface = true,
AdminAzureADTenant = "t",
AdminAzureADAudience = "a"
});
// Assert
var options = server.GetPrivateFieldValue<IWireMockMiddlewareOptions>("_options");
options.AuthenticationMatcher.Should().NotBeNull().And.BeOfType<AzureADAuthenticationMatcher>();
}
[Fact]
@@ -48,7 +66,7 @@ namespace WireMock.Net.Tests
// Assert
var options = server.GetPrivateFieldValue<IWireMockMiddlewareOptions>("_options");
Check.That(options.AuthorizationMatcher).IsNull();
Check.That(options.AuthenticationMatcher).IsNull();
}
[Fact]