From 2b63c74af93d263e6879abb947b0db80a1cf57b4 Mon Sep 17 00:00:00 2001 From: Matasx Date: Sun, 8 Dec 2024 15:10:43 +0100 Subject: [PATCH] #563 Allow to use multiple authentication methods (#575) --- .../GenHTTP.Modules.Authentication.csproj | 10 +- .../Multi/MultiAuthenticationConcern.cs | 83 +++++++ .../MultiAuthenticationConcernBuilder.cs | 88 ++++++++ Modules/Authentication/MultiAuthentication.cs | 16 ++ .../MultiAuthenticationTests.cs | 202 ++++++++++++++++++ 5 files changed, 394 insertions(+), 5 deletions(-) create mode 100644 Modules/Authentication/Multi/MultiAuthenticationConcern.cs create mode 100644 Modules/Authentication/Multi/MultiAuthenticationConcernBuilder.cs create mode 100644 Modules/Authentication/MultiAuthentication.cs create mode 100644 Testing/Acceptance/Modules/Authentication/MultiAuthenticationTests.cs diff --git a/Modules/Authentication/GenHTTP.Modules.Authentication.csproj b/Modules/Authentication/GenHTTP.Modules.Authentication.csproj index 172a01b4..f8d018b7 100644 --- a/Modules/Authentication/GenHTTP.Modules.Authentication.csproj +++ b/Modules/Authentication/GenHTTP.Modules.Authentication.csproj @@ -14,7 +14,7 @@ 9.3.0 Andreas Nägeli - + LICENSE https://genhttp.org/ @@ -35,20 +35,20 @@ - - + + - + - + diff --git a/Modules/Authentication/Multi/MultiAuthenticationConcern.cs b/Modules/Authentication/Multi/MultiAuthenticationConcern.cs new file mode 100644 index 00000000..af1682d5 --- /dev/null +++ b/Modules/Authentication/Multi/MultiAuthenticationConcern.cs @@ -0,0 +1,83 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Modules.Authentication.Multi; + +public sealed class MultiAuthenticationConcern : IConcern +{ + #region Get-/Setters + + public IHandler Content { get; } + + private readonly IConcern[] _delegatingConcerns; + + #endregion + + #region Initialization + + public MultiAuthenticationConcern(IHandler content, IConcern[] delegatingConcerns) + { + Content = content; + _delegatingConcerns = delegatingConcerns; + } + + #endregion + + #region Functionality + + public async ValueTask HandleAsync(IRequest request) + { + ResponseOrException? lastResponse = null; + foreach (var concern in _delegatingConcerns) + { + lastResponse?.Dispose(); + + try + { + lastResponse = new(await concern.HandleAsync(request)); + } + catch (ProviderException e) + { + lastResponse = new(Exception: e); + } + + if (lastResponse.Status != ResponseStatus.Unauthorized) + { + return lastResponse.Get(); + } + } + + return lastResponse?.Get() ?? + request.Respond() + .Status(ResponseStatus.Unauthorized) + .Build(); + } + + public ValueTask PrepareAsync() => Content.PrepareAsync(); + + #endregion + + #region Helper structure + + private sealed record ResponseOrException(IResponse? Response = null, ProviderException? Exception = null) : IDisposable + { + public IResponse? Get() + { + if (Exception != null) + { + throw Exception; + } + + return Response; + } + + public void Dispose() + { + Response?.Dispose(); + } + + public ResponseStatus? Status => Exception?.Status ?? Response?.Status.KnownStatus; + } + + #endregion +} diff --git a/Modules/Authentication/Multi/MultiAuthenticationConcernBuilder.cs b/Modules/Authentication/Multi/MultiAuthenticationConcernBuilder.cs new file mode 100644 index 00000000..8eaaac08 --- /dev/null +++ b/Modules/Authentication/Multi/MultiAuthenticationConcernBuilder.cs @@ -0,0 +1,88 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; +using GenHTTP.Modules.Authentication.ApiKey; +using GenHTTP.Modules.Authentication.Basic; +using GenHTTP.Modules.Authentication.Bearer; +using GenHTTP.Modules.Authentication.ClientCertificate; + +namespace GenHTTP.Modules.Authentication.Multi; + +/// +/// Builder for creating a multi-concern authentication handler. +/// +public sealed class MultiAuthenticationConcernBuilder : IConcernBuilder +{ + #region Fields + + private readonly List _delegatingConcernsBuilders = []; + + #endregion + + #region Private add functionality + + private MultiAuthenticationConcernBuilder AddIfNotNull(IConcernBuilder concernBuilder) + { + if (concernBuilder == null) return this; + + _delegatingConcernsBuilders.Add(concernBuilder); + return this; + } + + #endregion + + #region Functionality + + /// + /// Add nested API concern builder to this multi concern. + /// + /// Nested API key concern builder + public MultiAuthenticationConcernBuilder Add(ApiKeyConcernBuilder apiKeyBuilder) + => AddIfNotNull(apiKeyBuilder); + + /// + /// Add nested API concern builder to this multi concern. + /// + /// Nested Basic concern builder + public MultiAuthenticationConcernBuilder Add(BasicAuthenticationConcernBuilder basicBuilder) + => AddIfNotNull(basicBuilder); + + /// + /// Add nested API concern builder to this multi concern. + /// + /// Nested Basic Known Users concern builder + public MultiAuthenticationConcernBuilder Add(BasicAuthenticationKnownUsersBuilder basicKnownUsersBuilder) + => AddIfNotNull(basicKnownUsersBuilder); + + /// + /// Add nested API concern builder to this multi concern. + /// + /// Nested Bearer concern builder + public MultiAuthenticationConcernBuilder Add(BearerAuthenticationConcernBuilder bearerBuilder) + => AddIfNotNull(bearerBuilder); + + /// + /// Add nested API concern builder to this multi concern. + /// + /// Nested Client Certificate concern builder + public MultiAuthenticationConcernBuilder Add(ClientCertificateAuthenticationBuilder clientCertificateBuilder) + => AddIfNotNull(clientCertificateBuilder); + + /// + /// Construct the multi concern. + /// + /// + /// + public IConcern Build(IHandler content) + { + var delegatingConcerns = _delegatingConcernsBuilders.Select(x => x.Build(content)).ToArray(); + if (delegatingConcerns.Length == 0) throw new BuilderMissingPropertyException("Concerns"); + + return new MultiAuthenticationConcern( + content, + delegatingConcerns + ); + } + + #endregion + +} diff --git a/Modules/Authentication/MultiAuthentication.cs b/Modules/Authentication/MultiAuthentication.cs new file mode 100644 index 00000000..007ffe63 --- /dev/null +++ b/Modules/Authentication/MultiAuthentication.cs @@ -0,0 +1,16 @@ +using GenHTTP.Modules.Authentication.Multi; + +namespace GenHTTP.Modules.Authentication; + +public static class MultiAuthentication +{ + #region Builder + + /// + /// Creates a authentication handler that will use + /// underlying handlers to authenticate the request. + /// + public static MultiAuthenticationConcernBuilder Create() => new(); + + #endregion +} diff --git a/Testing/Acceptance/Modules/Authentication/MultiAuthenticationTests.cs b/Testing/Acceptance/Modules/Authentication/MultiAuthenticationTests.cs new file mode 100644 index 00000000..7e05f2fc --- /dev/null +++ b/Testing/Acceptance/Modules/Authentication/MultiAuthenticationTests.cs @@ -0,0 +1,202 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Content.Authentication; +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; +using GenHTTP.Modules.Authentication; +using GenHTTP.Modules.Authentication.Multi; +using GenHTTP.Modules.Functional; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Net; +using System.Net.Http.Headers; +using System.Text; + +namespace GenHTTP.Testing.Acceptance.Modules.Authentication; + +[TestClass] +public sealed class MultiAuthenticationTests +{ + #region Supporting data structures + + private const string ValidBearerToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + private class MockHandler : IHandler + { + public ValueTask HandleAsync(IRequest request) + { + throw new NotImplementedException(); + } + + public ValueTask PrepareAsync() + { + throw new NotImplementedException(); + } + } + + #endregion + + [TestMethod] + public void TestConcernFullBuild() + { + var builder = MultiAuthentication + .Create() + .Add(BearerAuthentication.Create()) + .Add(ApiKeyAuthentication.Create().Authenticator((_, _) => ValueTask.FromResult(null))) + .Add(BasicAuthentication.Create()) + .Add(BasicAuthentication.Create((_, _) => ValueTask.FromResult(null))) + .Add(ClientCertificateAuthentication.Create()) + ; + + var concern = builder.Build(new MockHandler()); + + Assert.IsInstanceOfType(concern); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestSingleBearerPass(TestEngine engine) + { + var builder = MultiAuthentication + .Create() + .Add(BearerAuthentication.Create().AllowExpired()) + ; + + using var response = await Execute(builder, engine, + request => request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ValidBearerToken)); + + await response.AssertStatusAsync(HttpStatusCode.OK); + + Assert.AreEqual("Secured", await response.GetContentAsync()); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestSingleBearerFail(TestEngine engine) + { + var builder = MultiAuthentication + .Create() + .Add(BearerAuthentication.Create().AllowExpired()) + ; + + using var response = await Execute(builder, engine); + + await response.AssertStatusAsync(HttpStatusCode.Unauthorized); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestSingleBasicPass(TestEngine engine) + { + var builder = MultiAuthentication + .Create() + .Add(BasicAuthentication.Create().Add("user", "pass")) + ; + + using var response = await Execute(builder, engine, + request => request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass")))); + + await response.AssertStatusAsync(HttpStatusCode.OK); + + Assert.AreEqual("Secured", await response.GetContentAsync()); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestSingleBasicFail(TestEngine engine) + { + var builder = MultiAuthentication + .Create() + .Add(BasicAuthentication.Create().Add("user", "pass")) + ; + + using var response = await Execute(builder, engine, + request => request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("user:invalidpass")))); + + await response.AssertStatusAsync(HttpStatusCode.Unauthorized); + AssertBasicChallenge(response); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestCombinedFirstPass(TestEngine engine) + { + var builder = MultiAuthentication + .Create() + .Add(BearerAuthentication.Create().AllowExpired()) + .Add(BasicAuthentication.Create().Add("user", "pass")) + ; + + using var response = await Execute(builder, engine, + request => request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ValidBearerToken)); + + await response.AssertStatusAsync(HttpStatusCode.OK); + + Assert.AreEqual("Secured", await response.GetContentAsync()); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestCombinedSecondPass(TestEngine engine) + { + var builder = MultiAuthentication + .Create() + .Add(BearerAuthentication.Create().AllowExpired()) + .Add(BasicAuthentication.Create().Add("user", "pass")) + ; + + using var response = await Execute(builder, engine, + request => request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass")))); + + await response.AssertStatusAsync(HttpStatusCode.OK); + + Assert.AreEqual("Secured", await response.GetContentAsync()); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestCombinedFail(TestEngine engine) + { + var builder = MultiAuthentication + .Create() + .Add(BearerAuthentication.Create().AllowExpired()) + .Add(BasicAuthentication.Create().Add("user", "pass")) + ; + + using var response = await Execute(builder, engine, + request => request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("user:invalidpass")))); + + await response.AssertStatusAsync(HttpStatusCode.Unauthorized); + AssertBasicChallenge(response); + } + + [TestMethod] + [MultiEngineTest] + public void TestEmpty(TestEngine engine) + { + var builder = MultiAuthentication.Create(); + + Assert.ThrowsException(() => + { + using var response = Execute(builder, engine).GetAwaiter().GetResult(); + }); + } + + private static void AssertBasicChallenge(HttpResponseMessage response) + { + Assert.IsNotNull(response.Headers.WwwAuthenticate.FirstOrDefault(x => x.Scheme == "Basic" && (x.Parameter?.StartsWith("realm") ?? false))); + } + + private static async Task Execute(MultiAuthenticationConcernBuilder builder, TestEngine engine, Action? authAction = null) + { + var handler = Inline.Create() + .Get(() => "Secured") + .Add(builder); + + await using var host = await TestHost.RunAsync(handler, engine: engine); + + var request = host.GetRequest(); + + authAction?.Invoke(request); + + return await host.GetResponseAsync(request); + } +}