diff --git a/.gitignore b/.gitignore index 8a5e7f13989c72b4b86131b33e8fe02c662b85ce..906aa3ecd39ffb7d3355cf89e2870a3736f3245c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,6 @@ private_notes/ **.nupkg - ### JetBrains template # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 diff --git a/BasicUnitTest/FluentSenderTests.cs b/BasicUnitTest/SenderTests.cs similarity index 98% rename from BasicUnitTest/FluentSenderTests.cs rename to BasicUnitTest/SenderTests.cs index 0951d3b8279130ae2069fe2e2fcee6c7a3ec1c16..e815e8ff29f1a2d31bf333c193767cc96b8cacbb 100644 --- a/BasicUnitTest/FluentSenderTests.cs +++ b/BasicUnitTest/SenderTests.cs @@ -9,7 +9,7 @@ using NUnit.Framework; namespace FluentApiTest; -public class FluentSenderTests { +public class SenderTests { private IContainer _container = null!; protected string clientId = null!; protected string clientSecret = null!; diff --git a/BasicUnitTest/FluentSubscriberReceiveTests.cs b/BasicUnitTest/SubscriberReceiveTests.cs similarity index 96% rename from BasicUnitTest/FluentSubscriberReceiveTests.cs rename to BasicUnitTest/SubscriberReceiveTests.cs index 6ab27366f830c539631570796c1088c38bc33373..88473285446ee3b3e862d6e269483393efbe320d 100644 --- a/BasicUnitTest/FluentSubscriberReceiveTests.cs +++ b/BasicUnitTest/SubscriberReceiveTests.cs @@ -7,7 +7,7 @@ using NUnit.Framework; namespace FluentApiTest; -public class FluentSubscriberReceiveTests { +public class SubscriberReceiveTests { private readonly string clientId = "clientId"; private readonly string clientSecret = "clientSecret"; private IContainer _container = null!; diff --git a/FitConnect/FitConnect.csproj b/FitConnect/FitConnect.csproj index 3827c420f787bd35cf4c8de00b4f830dda6be88e..844e93c8ef9d3947bda983a32b309a2ff8708a63 100644 --- a/FitConnect/FitConnect.csproj +++ b/FitConnect/FitConnect.csproj @@ -14,6 +14,7 @@ <PackageReference Include="Autofac" Version="6.4.0" /> <PackageReference Include="IdentityModel" Version="6.0.0" /> <PackageReference Include="jose-jwt" Version="4.0.0" /> + <PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" /> <PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="6.21.0" /> <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="6.21.0" /> diff --git a/FitConnect/Subscriber.cs b/FitConnect/Subscriber.cs index fcdd076df1df9e11f0efb0ec942da75d99b0a073..c0c6a3ae301153d7f0dd4cdf6816f5da4129240f 100644 --- a/FitConnect/Subscriber.cs +++ b/FitConnect/Subscriber.cs @@ -1,10 +1,16 @@ +using System.Net; using System.Reflection; +using System.Security.Cryptography; +using System.Text; using Autofac; +using Autofac.Core.Activators.Reflection; using FitConnect.Encryption; using FitConnect.Interfaces.Subscriber; using FitConnect.Models; using FitConnect.Models.v1.Api; using FitConnect.Services.Models.v1.Submission; +using IdentityModel; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using NJsonSchema; @@ -199,6 +205,38 @@ public class Subscriber : FitConnectClient, var result = CasesService.FinishSubmission(submission.CaseId, token); Logger?.LogInformation("Submission completed {status}", result); } + + public static string VerifyCallback(string callbackSecret, + long timestamp, string body) { + if (timestamp < DateTime.Now.AddMinutes(-5).ToEpochTime()) + throw new ArgumentException("Request is too old"); + + var hmac = new HMACSHA512(Encoding.UTF8.GetBytes(callbackSecret)) + .ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{body}")); + + + return Convert.ToHexString(hmac).ToLower(); + } + + public static bool VerifyCallback(string callbackSecret, HttpRequest request) { + if (!request.Headers.ContainsKey("callback-timestamp")) + throw new ArgumentException("Missing callback-timestamp header"); + + var timeStampString = request.Headers["callback-timestamp"].ToString(); + if (!long.TryParse(timeStampString, out var timestamp)) { + throw new ArgumentException("Invalid callback-timestamp header"); + } + + + var authentication = request.Headers["callback-authentication"]; + using var requestStream = request.Body; + var content = new StreamReader(requestStream).ReadToEnd().Trim(); + + var result = VerifyCallback(callbackSecret, timestamp, content); + if (result != authentication) + throw new ArgumentException("Verified request does not match authentication"); + return true; + } } public enum FinishSubmissionStatus { diff --git a/IntegrationTests/CallbackTest.cs b/IntegrationTests/CallbackTest.cs new file mode 100644 index 0000000000000000000000000000000000000000..827eaebb9ec9f45cf127bcda279751fc553c5e5c --- /dev/null +++ b/IntegrationTests/CallbackTest.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using Autofac; +using FluentAssertions; +using IdentityModel; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using MockContainer; +using Moq; +using NUnit.Framework; + +namespace IntegrationTests; + +[TestFixture] +public class CallbackTest { + private HttpRequest _request = null!; + private string _callbackSecret = ""; + + [SetUp] + public void Setup() { + var memoryStream = new MemoryStream(); + var streamWriter = new StreamWriter(memoryStream); + + streamWriter.WriteLine( + "{\"type\":\"https://schema.fitko.de/fit-connect/submission-api/callbacks/new-submissions\",\"submissionIds\":[\"f39ab143-d91a-474a-b69f-b00f1a1873c2\"]}"); + streamWriter.Flush(); + memoryStream.Position = 0; + + var headers = new HeaderDictionary(new Dictionary<string, StringValues>() { + { "callback-timestamp", "1672527599" }, { + "callback-authentication", + "798cd0edb70c08e5b32aa8a18cbbc8ff6b3078c51af6d011ff4e32e470c746234fc4314821fe5185264b029e962bd37de33f3b9fc5f1a93c40ce6672845e90df" + } + }); + + + var mock = + new Mock<HttpRequest>(); + mock.Setup(w => w.ContentType).Returns("application/json"); + mock.Setup(w => w.Headers).Returns(headers); + mock.Setup(w => w.Method).Returns("POST"); + mock.Setup(w => w.Body).Returns(memoryStream); + + _request = mock.Object; + + _callbackSecret = MockContainer.Container.Create().Resolve<MockSettings>().CallbackSecret; + } + + [Test] + public void ValidRequest_WithSingeValues() { + // Arrange + + //Act + var authentication = FitConnect.Subscriber.VerifyCallback(_callbackSecret, 1672527599, + "{\"type\":\"https://schema.fitko.de/fit-connect/submission-api/callbacks/new-submissions\",\"submissionIds\":[\"f39ab143-d91a-474a-b69f-b00f1a1873c2\"]}" + ); + + // Assert + authentication.Should() + .Be( + "798cd0edb70c08e5b32aa8a18cbbc8ff6b3078c51af6d011ff4e32e470c746234fc4314821fe5185264b029e962bd37de33f3b9fc5f1a93c40ce6672845e90df"); + } + + [Test] + public void ValidRequest() { + // Assert + FitConnect.Subscriber.VerifyCallback(_callbackSecret, _request).Should().Be(true); + } + + [Test] + public void RequestAge_Fails() { + // Arrange + _request.Headers["callback-timestamp"] = "1641066653"; + + // Atc + // Assert + Assert.Throws<ArgumentException>(() => { + FitConnect.Subscriber.VerifyCallback(_callbackSecret, _request); + }) + .Message.Should().Be("Request is too old"); + } + + [Test] + public void RequestAuthentication_Fails() { + // Arrange + _request.Headers["callback-authentication"] = + "898cd0edb70c08e5b32aa8a18cbbc8ff6b3078c51af6d011ff4e32e470c746234fc4314821fe5185264b029e962bd37de33f3b9fc5f1a93c40ce6672845e90df"; + + // Atc + // Assert + Assert.Throws<ArgumentException>(() => { + FitConnect.Subscriber.VerifyCallback(_callbackSecret, _request); + }) + .Message.Should().Be("Request is not authentic"); + } +} diff --git a/IntegrationTests/IntegrationTests.csproj b/IntegrationTests/IntegrationTests.csproj index d95a0c10813bbec26c5f71ac327cc1d9ab2f2030..436a6021978121275852cebc0475778fb12d8e4b 100644 --- a/IntegrationTests/IntegrationTests.csproj +++ b/IntegrationTests/IntegrationTests.csproj @@ -10,6 +10,8 @@ <ItemGroup> <PackageReference Include="DotNet.Testcontainers" Version="1.6.0" /> <PackageReference Include="FluentAssertions" Version="6.7.0" /> + <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" /> + <PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="6.0.0" /> <PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="6.21.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> diff --git a/MockContainer/MockContainer.cs b/MockContainer/MockContainer.cs index 2bfa17bde88b8a27fb6ef0f67c70d0a278edcc30..5fd91d97126af1503d3d4699f2c2881d80b5342d 100644 --- a/MockContainer/MockContainer.cs +++ b/MockContainer/MockContainer.cs @@ -22,7 +22,7 @@ namespace MockContainer; public record MockSettings(string PrivateKeyDecryption, string PrivateKeySigning, string PublicKeyEncryption, string PublicKeySignatureVerification, string SenderClientId, string SenderClientSecret, string SubscriberClientId, string SubscriberClientSecret, - string DestinationId, string LeikaKey); + string DestinationId, string LeikaKey, string CallbackSecret); public class TestFile { public byte[] Content; @@ -80,13 +80,14 @@ public static class Container { var subscriberClientSecret = (string)credentials.subscriber.clientSecret; var destinationId = (string)credentials.destinationId; var leikaKey = (string)credentials.leikaKey; + var callbackSecret = (string)credentials.callbackSecret; builder.Register(c => new MockSettings( privateKeyDecryption, privateKeySigning, publicKeyEncryption, publicKeySignature, senderClientId, senderClientSecret, subscriberClientId, subscriberClientSecret, - destinationId, leikaKey)) + destinationId, leikaKey, callbackSecret)) .As<MockSettings>(); builder.Register(c => new KeySet { PrivateKeyDecryption = privateKeyDecryption,