diff --git a/FitConnect/Encryption/FitEncryption.cs b/FitConnect/Encryption/FitEncryption.cs index c5005d8701b3437ca075ed912f5ef7a055e2840c..2e66b7de9be072d86f2d8b2511184da11f5d07c8 100644 --- a/FitConnect/Encryption/FitEncryption.cs +++ b/FitConnect/Encryption/FitEncryption.cs @@ -236,8 +236,8 @@ public class FitEncryption { logger?.LogTrace("Verifying JWT {Token}", signature); - // if (key.KeySize != 4096) - // throw new Exception("Key size must be 4096"); + if (key.KeySize != 4096) + throw new Exception("Key size must be 4096"); var result = tokenHandler.ValidateToken(signature, new TokenValidationParameters { ValidAlgorithms = new[] { "PS512" }, @@ -256,9 +256,6 @@ public class FitEncryption { RequireExpirationTime = false, IgnoreTrailingSlashWhenValidatingAudience = true, TryAllIssuerSigningKeys = true - - // set clockskew to zero so tokens expire exactly at token expiration time (instead of 5 minutes later) - // ClockSkew = TimeSpan.Zero }); var error = result.Exception?.Message; diff --git a/FitConnect/Models/SecurityEventToken.cs b/FitConnect/Models/SecurityEventToken.cs index 91ac89b85d26f23db3b9af249c65305e99414585..ded9ac85a4951dfb2d712cfafece3dba3bfc4f77 100644 --- a/FitConnect/Models/SecurityEventToken.cs +++ b/FitConnect/Models/SecurityEventToken.cs @@ -39,7 +39,6 @@ public class SecurityEventToken { public const string DeleteSubmissionSchema = "https://schema.fitko.de/fit-connect/events/delete-submission"; - public SecurityEventToken(string jwtEncodedString) { TokenString = jwtEncodedString; EventType = DecodeEventType(Token.Claims); diff --git a/FitConnect/Router.cs b/FitConnect/Router.cs index 51d28dce0aa17511d9579d3b545cf1dde2dee3d9..f8bec1ab57747d8259ece32cefa9dd69da124ff2 100644 --- a/FitConnect/Router.cs +++ b/FitConnect/Router.cs @@ -3,12 +3,13 @@ using FitConnect.Encryption; using FitConnect.Models; using FitConnect.Services; using FitConnect.Services.Interfaces; -using IdentityModel; +using Jose; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; +using Base64Url = IdentityModel.Base64Url; using Route = FitConnect.Services.Models.v1.Routes.Route; namespace FitConnect; @@ -37,15 +38,40 @@ internal class Router : IRouter { foreach (var route in routes) { _logger?.LogInformation("Testing destination {DestinationId}", route.DestinationId); var verifyJwt = await VerifyDestinationSignature(route); + if (!verifyJwt) { + throw new Exception($"Invalid destination signature"); + } verifyJwt &= await VerifyDestinationParametersSignature(route); - if (!verifyJwt) throw new Exception("Invalid signature"); + if (!verifyJwt) { + throw new Exception($"Invalid destination parameter signature"); + } + + verifyJwt &= VerifySubmissionHost(route); + + if (!verifyJwt) { + throw new Exception( + $"SubmissionHost does not match DestinationParameters SubmissionUrl"); + } } return routes; } + private bool VerifySubmissionHost(Route route) { + var signature = new JsonWebToken(route.DestinationSignature); + var payload = + JsonConvert.DeserializeObject<dynamic>( + Base64UrlEncoder.Decode(signature.EncodedPayload)); + + string? submissionHost = payload?.submissionHost; + if (submissionHost == null) return false; + + var destinationUri = new Uri(route.DestinationParameters.SubmissionUrl); + + return destinationUri.Host == submissionHost; + } /// <summary> /// Finding Areas @@ -63,10 +89,10 @@ internal class Router : IRouter { } private async Task<bool> VerifyDestinationParametersSignature(Route route) { + // Get Key from SubmissionAPI var submissionKey = await GetSubmissionServiceValidationJwk(route.DestinationParameters.SubmissionUrl); - // Get Key from SubmissionAPI var parameterJson = JsonConvert.SerializeObject(route.DestinationParameters, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, @@ -111,11 +137,27 @@ internal class Router : IRouter { var result = await client.GetAsync("/.well-known/jwks.json"); return await result.Content.ReadAsStringAsync(); } + + + /// <summary> + /// Finding Areas + /// </summary> + /// <param name="filter"></param> + /// <param name="totalCount"></param> + /// <param name="offset"></param> + /// <param name="limit"></param> + /// <returns></returns> + public IEnumerable<Area> GetAreas(string filter, out int totalCount, int offset = 0, + int limit = 100) { + var dto = _routeService.GetAreas(filter, offset, limit).Result; + totalCount = dto?.TotalCount ?? 0; + return dto?.Areas ?? new List<Area>(); + } } public class OrderedContractResolver : DefaultContractResolver { - protected override IList<JsonProperty> CreateProperties(Type type, - MemberSerialization memberSerialization) { + protected override System.Collections.Generic.IList<JsonProperty> CreateProperties( + System.Type type, MemberSerialization memberSerialization) { NamingStrategy = new CamelCaseNamingStrategy(); return base.CreateProperties(type, memberSerialization).OrderBy(p => p.PropertyName) .ToList(); diff --git a/FitConnect/Services/RouteService.cs b/FitConnect/Services/RouteService.cs index e64ae440b201d9ab8212e04243c2f7d73557b059..5a17a872274d0efa5238d7da0c4473fa7f429017 100644 --- a/FitConnect/Services/RouteService.cs +++ b/FitConnect/Services/RouteService.cs @@ -22,8 +22,7 @@ internal class RouteService : RestCallService, IRouteService { public async Task<List<Route>> GetDestinationIdAsync(string leikaKey, string? ags, string? ars, string? areaId) { - if (ars == null && ags == null && areaId == null) - throw new ArgumentException("Either ars, ags or areaId must be specified."); + ValidateParametersForGetDestinationId(ags, ars, areaId); var result = await RestCall<RoutesListDto>($"/routes?leikaKey={leikaKey}" + (ags != null ? $"&ags={ags}" : "") + @@ -34,6 +33,19 @@ internal class RouteService : RestCallService, IRouteService { return result?.Routes?.ToList() ?? new List<Route>(); } + private void ValidateParametersForGetDestinationId(string? ags, string? ars, string? areaId) { + if (ars == null && ags == null && areaId == null) + throw new ArgumentException("Either ars, ags or areaId must be specified."); + + var paramCount = 0; + if (ags != null) paramCount++; + if (ars != null) paramCount++; + if (areaId != null) paramCount++; + + if (paramCount != 1) + throw new ArgumentException("Only one of ars, ags or areaId must be specified."); + } + public async Task<AreaList?> GetAreas(string filter, int offset = 0, int limit = 100) { var result = await RestCall<AreaList>( $"/areas?areaSearchexpression={filter}&offset={offset}&limit={limit}", diff --git a/FitConnect/Subscriber.cs b/FitConnect/Subscriber.cs index f957e3b36aaa3dacb266e8699d3f75ba7e3b3046..641281202e8d9c766aaf48287e2b2762bf572d0d 100644 --- a/FitConnect/Subscriber.cs +++ b/FitConnect/Subscriber.cs @@ -219,7 +219,7 @@ public class Subscriber : FitConnectClient, var result = VerifyCallback(callbackSecret, timestamp, content); if (result != authentication) - throw new ArgumentException("Request is not authentic"); + throw new ArgumentException("Verified request does not match authentication"); return true; } } diff --git a/IntegrationTests/CallbackTest.cs b/IntegrationTests/CallbackTest.cs index 52a30c78eae27d8bebaab0a8b3afd50b25d93e67..503ca89c6bd85dfeff8590bb81987eae1b2dde78 100644 --- a/IntegrationTests/CallbackTest.cs +++ b/IntegrationTests/CallbackTest.cs @@ -13,22 +13,12 @@ namespace IntegrationTests; [TestFixture] public class CallbackTest { + private HttpRequest _request = null!; + private string _callbackSecret = ""; + + [SetUp] public void Setup() { - // POST /callbacks/fit-connect - // callback-authentication: 798cd0edb70c08e5b32aa8a18cbbc8ff6b3078c51af6d011ff4e32e470c746234fc4314821fe5185264b029e962bd37de33f3b9fc5f1a93c40ce6672845e90df - // callback-timestamp: 1672527599 - // - // {"type":"https://schema.fitko.de/fit-connect/submission-api/callbacks/new-submissions","submissionIds":["f39ab143-d91a-474a-b69f-b00f1a1873c2"]} - - // HttpRequest request = null!; - // request.Headers - // request.Headers.Add("callback-authentication", - // "798cd0edb70c08e5b32aa8a18cbbc8ff6b3078c51af6d011ff4e32e470c746234fc4314821fe5185264b029e962bd37de33f3b9fc5f1a93c40ce6672845e90df"); - // request.Headers.Add("callback-timestamp", DateTime.Now.ToEpochTime().ToString()); - // request.Method = "POST"; - // request.ContentType = "application/json"; - var memoryStream = new MemoryStream(); var streamWriter = new StreamWriter(memoryStream); @@ -37,6 +27,7 @@ public class CallbackTest { streamWriter.Flush(); memoryStream.Position = 0; + // Request = new DefaultHttpRequest(new DefaultHttpContext()) { // Body = new StreamBody(memoryStream) // }; @@ -56,13 +47,11 @@ public class CallbackTest { mock.Setup(w => w.Method).Returns("POST"); mock.Setup(w => w.Body).Returns(memoryStream); - Request = mock.Object; + _request = mock.Object; _callbackSecret = Container.Create().Resolve<MockSettings>().CallbackSecret; } - private HttpRequest Request = null!; - private string _callbackSecret = ""; [Test] public void ValidRequest_WithSingeValues() { @@ -82,33 +71,33 @@ public class CallbackTest { [Test] public void ValidRequest() { // Assert - FitConnect.Subscriber.VerifyCallback(_callbackSecret, Request).Should().Be(true); + FitConnect.Subscriber.VerifyCallback(_callbackSecret, _request).Should().Be(true); } [Test] public void RequestAge_Fails() { // Arrange - Request.Headers["callback-timestamp"] = "1641066653"; + _request.Headers["callback-timestamp"] = "1641066653"; // Atc // Assert Assert.Throws<ArgumentException>(() => { - FitConnect.Subscriber.VerifyCallback(_callbackSecret, Request); - })! + FitConnect.Subscriber.VerifyCallback(_callbackSecret, _request); + }) .Message.Should().Be("Request is too old"); } [Test] public void RequestAuthentication_Fails() { // Arrange - Request.Headers["callback-authentication"] = + _request.Headers["callback-authentication"] = "898cd0edb70c08e5b32aa8a18cbbc8ff6b3078c51af6d011ff4e32e470c746234fc4314821fe5185264b029e962bd37de33f3b9fc5f1a93c40ce6672845e90df"; // Atc // Assert Assert.Throws<ArgumentException>(() => { - FitConnect.Subscriber.VerifyCallback(_callbackSecret, Request); - })! - .Message.Should().Be("Request is not authentic"); + FitConnect.Subscriber.VerifyCallback(_callbackSecret, _request); + }) + .Message.Should().Be("Verified request does not match authentication"); } } diff --git a/IntegrationTests/Routing/RoutingTests.cs b/IntegrationTests/Routing/RoutingTests.cs index 9839e2d8d9cd0f62b036f418dfb93478df2fbb5d..c87cbd5d8770a864828b85262e4ce4efbc965b5f 100644 --- a/IntegrationTests/Routing/RoutingTests.cs +++ b/IntegrationTests/Routing/RoutingTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Text; using FitConnect; @@ -92,6 +92,32 @@ public class RoutingTests { areas.Should().HaveCountGreaterThan(0); } + [Test] + public void FindDestination_WithAreaIdAndAgs_ShouldThrowException() { + // Arrange + var leika = "99123456760610"; + var ags = "06435014"; + var areaId = "931"; + + // Act + Action action = () => _router.FindDestinationsAsync(leika, ags, areaId).Wait(); + + // Assert + action.Should().Throw<ArgumentException>(); + } + + [Test] + public void FindDestination_WithoutAgsAndAreaId_ShouldThrowException() { + // Arrange + var leika = "99123456760610"; + + // Act + Action action = () => _router.FindDestinationsAsync(leika).Wait(); + + // Assert + action.Should().Throw<ArgumentException>(); + } + [Test] [Order(80)] public void BaseSignatureTest() { diff --git a/IntegrationTests/Subscriber/SubscriberTestUnHappyPath.cs b/IntegrationTests/Subscriber/SubscriberTestUnHappyPath.cs index 20c6ba8b62f990d96a0c70dccb6b607cff0a94e7..f875748607e077c633de336cabfc92f9478c4165 100644 --- a/IntegrationTests/Subscriber/SubscriberTestUnHappyPath.cs +++ b/IntegrationTests/Subscriber/SubscriberTestUnHappyPath.cs @@ -16,6 +16,7 @@ public class SubscriberTestUnHappyPath : SubscriberTestBase { // Act var valid = JsonHelper.VerifyMetadata(wrongData); + validationErrors.ToList().ForEach(v => Logger?.LogWarning("ERROR: {V}", v.ToString())); // Assert valid.Should().BeFalse(); @@ -29,6 +30,7 @@ public class SubscriberTestUnHappyPath : SubscriberTestBase { // Act && Assert Assert.Throws<JsonReaderException>(() => { var valid = JsonHelper.VerifyMetadata(wrongData, Logger); + validationErrors.ToList().ForEach(v => Logger.LogWarning("ERROR: {V}", v.ToString())); }); } } diff --git a/changelog.md b/changelog.md index 1a588142cbbc9dcf8188bb279c128e462a77aedf..5b1261b9c0fae81a4b72a89e7747254dac4332ca 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,12 @@ # Changelog +All notable changes to the FIT-Connect .NET SDK will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/), +and this project adheres to [Semantic Versioning](https://semver.org/). + ## [Unreleased] + +## [1.0.0] - TBD +### Added +- initial version of the SDK to send submissions and subscribe to destinations diff --git a/readme.md b/readme.md index d8d28ef326e036a932a58e9f13e963b38af8748d..c50b7763326bd4b664f03eae928e2e9a3bc5683d 100644 --- a/readme.md +++ b/readme.md @@ -11,10 +11,9 @@ For an implementation example take a look at the [DemoRunner](DemoRunner/Program **FIT-Connect .NET SDK** is a .NET library for the FIT-Connect API. -Die Verwendung des SDKs ist in der [documentation (ger)](./Documentation/documentation.de-DE.md) -erklärt +Die Verwendung des SDKs ist in der [documentation (ger)](./Documentation/documentation.de-DE.md) erklärt. -## For information how to use the SDK and FitConnect visit: +## For information how to use the SDK and FIT-Connect visit: * [SDK-Dokumentation](./documentation/documentation.de-DE.md) * [FIT-Connect Dokumentation](https://docs.fitko.de/FIT-Connect/docs) @@ -29,7 +28,7 @@ For the structure look the [Structure documentation](structure.md) ## Environment -Das FitConnect SDK kann an die folgenden Umgebungen angeschlossen werden: +Das FIT-Connect SDK kann an die folgenden Umgebungen angeschlossen werden: - Testing: ```FitConnectEnvironment.Testing``` - Staging: ```FitConnectEnvironment.Staging``` @@ -67,7 +66,7 @@ Der Parameter ```logger``` ist optional und muss das Interface ```Microsoft.Exte ## .WithDestination(destinationId) -Die Destination ID des Zustelldienstes muss an dieser Stelle übergeben werden. +Die `destinationId` des Zustellpunktes, an den die Submission geschickt werden soll, muss an dieser Stelle übergeben werden. _Noch nicht vollständig getestet!_<br> Eine Möglichkeit, die Destination ID zu ermitteln, geht über die Methode ``FindDestinationId()`` des Senders.