From 4e95986648c3dba78f8258a4ec8a68896570fbe2 Mon Sep 17 00:00:00 2001 From: Dovchik Date: Wed, 24 Apr 2024 12:56:03 +0200 Subject: [PATCH 1/4] implement validate request method --- src/Sinch/SinchClient.cs | 18 ++--- src/Sinch/Voice/SinchVoiceClient.cs | 73 +++++++++++++++++++- tests/Sinch.Tests/Voice/HooksTests.cs | 38 ++++++++++ tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs | 2 +- 4 files changed, 115 insertions(+), 16 deletions(-) create mode 100644 tests/Sinch.Tests/Voice/HooksTests.cs diff --git a/src/Sinch/SinchClient.cs b/src/Sinch/SinchClient.cs index 57f4f920..e682c41d 100644 --- a/src/Sinch/SinchClient.cs +++ b/src/Sinch/SinchClient.cs @@ -109,13 +109,8 @@ public ISinchVerificationClient Verification(string appKey, string appSecret, /// /// /// See . Defaults to - /// - /// Choose which authentication to use. - /// Defaults to Application Sign request and it's a recommended approach. - /// /// - public ISinchVoiceClient Voice(string appKey, string appSecret, CallingRegion? callingRegion = null, - AuthStrategy authStrategy = AuthStrategy.ApplicationSign); + public ISinchVoiceClient Voice(string appKey, string appSecret, CallingRegion? callingRegion = null); } public class SinchClient : ISinchClient @@ -271,8 +266,7 @@ public ISinchVerificationClient Verification(string appKey, string appSecret, /// public ISinchVoiceClient Voice(string appKey, string appSecret, - CallingRegion? callingRegion = default, - AuthStrategy authStrategy = AuthStrategy.ApplicationSign) + CallingRegion? callingRegion = default) { if (string.IsNullOrEmpty(appKey)) throw new ArgumentNullException(nameof(appKey), "The value should be present"); @@ -280,18 +274,14 @@ public ISinchVoiceClient Voice(string appKey, string appSecret, if (string.IsNullOrEmpty(appSecret)) throw new ArgumentNullException(nameof(appSecret), "The value should be present"); - ISinchAuth auth; - if (authStrategy == AuthStrategy.ApplicationSign) - auth = new ApplicationSignedAuth(appKey, appSecret); - else - auth = new BasicAuth(appKey, appSecret); + ISinchAuth auth = new ApplicationSignedAuth(appKey, appSecret); callingRegion ??= CallingRegion.Global; var http = new Http(auth, _httpClient, _loggerFactory?.Create(), JsonNamingPolicy.CamelCase); return new SinchVoiceClient( new Uri(_apiUrlOverrides?.VoiceUrl ?? string.Format(VoiceApiUrlTemplate, callingRegion.Value)), - _loggerFactory, http); + _loggerFactory, http, (auth as ApplicationSignedAuth)!); } private void ValidateCommonCredentials() diff --git a/src/Sinch/Voice/SinchVoiceClient.cs b/src/Sinch/Voice/SinchVoiceClient.cs index fa3c2809..328de284 100644 --- a/src/Sinch/Voice/SinchVoiceClient.cs +++ b/src/Sinch/Voice/SinchVoiceClient.cs @@ -1,4 +1,11 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Primitives; +using Sinch.Auth; using Sinch.Core; using Sinch.Logger; using Sinch.Voice.Applications; @@ -30,14 +37,30 @@ public interface ISinchVoiceClient /// You can use the API to manage features of applications in your project. /// ISinchVoiceApplications Applications { get; } + + /// + /// Validates callback request. + /// + /// + /// + /// + /// + /// True, if produced signature match with that of a header. + bool ValidateAuthenticationHeader(HttpMethod method, string path, Dictionary headers, + JsonObject body); } /// internal class SinchVoiceClient : ISinchVoiceClient { + private readonly ApplicationSignedAuth _applicationSignedAuth; + private readonly ILoggerAdapter? _logger; + public SinchVoiceClient(Uri baseAddress, LoggerFactory? loggerFactory, - IHttp http) + IHttp http, ApplicationSignedAuth applicationSignedAuth) { + _applicationSignedAuth = applicationSignedAuth; + _logger = loggerFactory?.Create(); Callouts = new SinchCallout(loggerFactory?.Create(), baseAddress, http); Calls = new SinchCalls(loggerFactory?.Create(), baseAddress, http); Conferences = new SinchConferences(loggerFactory?.Create(), baseAddress, http); @@ -55,5 +78,53 @@ public SinchVoiceClient(Uri baseAddress, LoggerFactory? loggerFactory, /// public ISinchVoiceApplications Applications { get; } + + public bool ValidateAuthenticationHeader(HttpMethod method, string path, + Dictionary headers, JsonObject body) + { + var headersCaseInsensitive = + new Dictionary(headers, StringComparer.InvariantCultureIgnoreCase); + + if (!headersCaseInsensitive.TryGetValue("authorization", out var authHeaderValue)) + { + _logger?.LogDebug("Failed to validate auth header. Authorization header is missing."); + return false; + } + + if (authHeaderValue.Count == 0) + { + _logger?.LogDebug("Failed to validate auth header. Authorization header values is missing."); + return false; + } + + var authSignature = authHeaderValue.FirstOrDefault(); + if (authSignature == null) + { + _logger?.LogDebug("Failed to validate auth header. Authorization header value is null."); + return false; + } + + const string timestampHeader = "x-timestamp"; + var bytesBody = JsonSerializer.SerializeToUtf8Bytes(body); + var contentType = headersCaseInsensitive.GetValueOrDefault("content-type"); + var timestamp = headersCaseInsensitive.GetValueOrDefault(timestampHeader, string.Empty); + var calculatedSignature = // passing empty timestamp is okay + _applicationSignedAuth.GetSignedAuth(bytesBody, method.Method, path, + string.Join(':', timestampHeader, timestamp), contentType); + var signature = authSignature.Split(' ').Skip(1).FirstOrDefault(); + + if (signature == null) + { + _logger?.LogDebug("Failed to validate auth header. Signature is missing from the header."); + return false; + } + + _logger?.LogDebug("{CalculatedSignature}", calculatedSignature); + _logger?.LogDebug("{AuthorizationSignature}", signature); + + var isValidSignature = string.Equals(signature, calculatedSignature, StringComparison.Ordinal); + _logger?.LogInformation("The signature was validated with {success}", isValidSignature); + return isValidSignature; + } } } diff --git a/tests/Sinch.Tests/Voice/HooksTests.cs b/tests/Sinch.Tests/Voice/HooksTests.cs new file mode 100644 index 00000000..69109974 --- /dev/null +++ b/tests/Sinch.Tests/Voice/HooksTests.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json.Nodes; +using FluentAssertions; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Sinch.Tests.Voice +{ + public class HooksTests + { + [Fact] + public void ValidateRequest() + { + // https://developers.sinch.com/docs/voice/api-reference/authentication/callback-signed-request/ + // full path: "https://callbacks.yourdomain.com/sinch/callback/ace" + var voiceClient = new SinchClient(default, default, default).Voice("669E367E-6BBA-48AB-AF15-266871C28135", + "BeIukql3pTKJ8RGL5zo0DA=="); + var body = + "{\"event\":\"ace\",\"callid\":\"822aa4b7-05b4-4d83-87c7-1f835ee0b6f6_257\",\"timestamp\":\"2014-09-24T10:59:41Z\",\"version\":1}"; + + voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + { + "authorization", + new[] + { + "application 669E367E-6BBA-48AB-AF15-266871C28135:Tg6fMyo8mj9pYfWQ9ssbx3Tc1BNC87IEygAfLbJqZb4=" + } + } + } + , JsonNode.Parse(body)!.AsObject()).Should().BeTrue(); + } + } +} diff --git a/tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs b/tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs index 881f3bac..c75f8410 100644 --- a/tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs +++ b/tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs @@ -9,7 +9,7 @@ public class VoiceTestBase : TestBase protected VoiceTestBase() { - VoiceClient = SinchClientMockServer.Voice("app_key", "app_secret", default, AuthStrategy.Basic); + VoiceClient = SinchClientMockServer.Voice("app_key", "app_secret", default); } } } From 66f78c0f5eccc94232a6bee552cbe93ef87bf0c5 Mon Sep 17 00:00:00 2001 From: Dovchik Date: Wed, 24 Apr 2024 12:59:56 +0200 Subject: [PATCH 2/4] fix: pass valid base64 strings --- tests/Sinch.Tests/Voice/HooksTests.cs | 2 +- tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Sinch.Tests/Voice/HooksTests.cs b/tests/Sinch.Tests/Voice/HooksTests.cs index 69109974..12b19d9f 100644 --- a/tests/Sinch.Tests/Voice/HooksTests.cs +++ b/tests/Sinch.Tests/Voice/HooksTests.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Net.Http; using System.Text.Json.Nodes; using FluentAssertions; diff --git a/tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs b/tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs index c75f8410..3215c523 100644 --- a/tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs +++ b/tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs @@ -9,7 +9,7 @@ public class VoiceTestBase : TestBase protected VoiceTestBase() { - VoiceClient = SinchClientMockServer.Voice("app_key", "app_secret", default); + VoiceClient = SinchClientMockServer.Voice("669E367E-6BBA-48AB-AF15-266871C28135", "BeIukql3pTKJ8RGL5zo0DA==", default); } } } From 33d12ed5824a3e9e876253ee90aa0b26d178533a Mon Sep 17 00:00:00 2001 From: Dovchik Date: Wed, 24 Apr 2024 13:05:17 +0200 Subject: [PATCH 3/4] fix: format --- src/Sinch/Voice/SinchVoiceClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sinch/Voice/SinchVoiceClient.cs b/src/Sinch/Voice/SinchVoiceClient.cs index 328de284..d653fd6f 100644 --- a/src/Sinch/Voice/SinchVoiceClient.cs +++ b/src/Sinch/Voice/SinchVoiceClient.cs @@ -108,7 +108,7 @@ public bool ValidateAuthenticationHeader(HttpMethod method, string path, var bytesBody = JsonSerializer.SerializeToUtf8Bytes(body); var contentType = headersCaseInsensitive.GetValueOrDefault("content-type"); var timestamp = headersCaseInsensitive.GetValueOrDefault(timestampHeader, string.Empty); - var calculatedSignature = // passing empty timestamp is okay + var calculatedSignature = _applicationSignedAuth.GetSignedAuth(bytesBody, method.Method, path, string.Join(':', timestampHeader, timestamp), contentType); var signature = authSignature.Split(' ').Skip(1).FirstOrDefault(); From f02b6caf9d0a0f11198e61afcd9a1ed7bf539e57 Mon Sep 17 00:00:00 2001 From: Dovchik Date: Thu, 25 Apr 2024 12:19:12 +0200 Subject: [PATCH 4/4] feat: add more tests for validation --- src/Sinch/Voice/SinchVoiceClient.cs | 13 ++- tests/Sinch.Tests/Voice/HooksTests.cs | 162 +++++++++++++++++++++++++- 2 files changed, 167 insertions(+), 8 deletions(-) diff --git a/src/Sinch/Voice/SinchVoiceClient.cs b/src/Sinch/Voice/SinchVoiceClient.cs index d653fd6f..6eb30e9a 100644 --- a/src/Sinch/Voice/SinchVoiceClient.cs +++ b/src/Sinch/Voice/SinchVoiceClient.cs @@ -111,9 +111,18 @@ public bool ValidateAuthenticationHeader(HttpMethod method, string path, var calculatedSignature = _applicationSignedAuth.GetSignedAuth(bytesBody, method.Method, path, string.Join(':', timestampHeader, timestamp), contentType); - var signature = authSignature.Split(' ').Skip(1).FirstOrDefault(); + var splitAuthHeader = authSignature.Split(' '); - if (signature == null) + if (splitAuthHeader.FirstOrDefault() != "application") + { + _logger?.LogDebug( + "Failed to validate auth header. Authorization header not starting from 'application'."); + return false; + } + + var signature = splitAuthHeader.Skip(1).FirstOrDefault(); + + if (string.IsNullOrEmpty(signature)) { _logger?.LogDebug("Failed to validate auth header. Signature is missing from the header."); return false; diff --git a/tests/Sinch.Tests/Voice/HooksTests.cs b/tests/Sinch.Tests/Voice/HooksTests.cs index 12b19d9f..75bc785d 100644 --- a/tests/Sinch.Tests/Voice/HooksTests.cs +++ b/tests/Sinch.Tests/Voice/HooksTests.cs @@ -3,23 +3,96 @@ using System.Text.Json.Nodes; using FluentAssertions; using Microsoft.Extensions.Primitives; +using Sinch.Voice; using Xunit; namespace Sinch.Tests.Voice { public class HooksTests { + private readonly ISinchVoiceClient _voiceClient = new SinchClient(default, default, default).Voice( + "669E367E-6BBA-48AB-AF15-266871C28135", + "BeIukql3pTKJ8RGL5zo0DA=="); + + private string _body = + "{\"event\":\"ace\",\"callid\":\"822aa4b7-05b4-4d83-87c7-1f835ee0b6f6_257\",\"timestamp\":\"2014-09-24T10:59:41Z\",\"version\":1}"; + [Fact] public void ValidateRequest() { // https://developers.sinch.com/docs/voice/api-reference/authentication/callback-signed-request/ // full path: "https://callbacks.yourdomain.com/sinch/callback/ace" - var voiceClient = new SinchClient(default, default, default).Voice("669E367E-6BBA-48AB-AF15-266871C28135", - "BeIukql3pTKJ8RGL5zo0DA=="); - var body = - "{\"event\":\"ace\",\"callid\":\"822aa4b7-05b4-4d83-87c7-1f835ee0b6f6_257\",\"timestamp\":\"2014-09-24T10:59:41Z\",\"version\":1}"; - voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + { + "authorization", + new[] + { + "application 669E367E-6BBA-48AB-AF15-266871C28135:Tg6fMyo8mj9pYfWQ9ssbx3Tc1BNC87IEygAfLbJqZb4=" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeTrue(); + } + + [Fact] + public void FailIfInvalidAuthHeaderValue() + { + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + { + "authorization", + new[] + { + "application 669E367E-6BBA-48AB-AF15-266871C28135:bdJO/XUVvIsb5SlZAKmvfw==" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + + [Fact] + public void FailIfAuthHeaderMissing() + { + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + + [Fact] + public void FailIfInvalidPath() + { + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/not/that/path", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + { + "authorization", + new[] + { + "application 669E367E-6BBA-48AB-AF15-266871C28135:Tg6fMyo8mj9pYfWQ9ssbx3Tc1BNC87IEygAfLbJqZb4=" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + + [Fact] + public void FailNotThatHttpMethod() + { + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Get, "/sinch/callback/ace", new Dictionary() { { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, @@ -32,7 +105,84 @@ public void ValidateRequest() } } } - , JsonNode.Parse(body)!.AsObject()).Should().BeTrue(); + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + + [Fact] + public void FailNotThatTimestamp() + { + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2019-11-03T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + { + "authorization", + new[] + { + "application 669E367E-6BBA-48AB-AF15-266871C28135:Tg6fMyo8mj9pYfWQ9ssbx3Tc1BNC87IEygAfLbJqZb4=" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + + [Fact] + public void FailNotThatContentType() + { + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "text" } }, + { + "authorization", + new[] + { + "application 669E367E-6BBA-48AB-AF15-266871C28135:Tg6fMyo8mj9pYfWQ9ssbx3Tc1BNC87IEygAfLbJqZb4=" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + + [Fact] + public void FailNotThatBody() + { + _body = JsonNode.Parse("{\"hello\": \"world\"}")!.ToJsonString(); + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + { + "authorization", + new[] + { + "application 669E367E-6BBA-48AB-AF15-266871C28135:Tg6fMyo8mj9pYfWQ9ssbx3Tc1BNC87IEygAfLbJqZb4=" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + + [Fact] + public void FailNotApplicationHeader() + { + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + { + "authorization", + new[] + { + "authorization 669E367E-6BBA-48AB-AF15-266871C28135:Tg6fMyo8mj9pYfWQ9ssbx3Tc1BNC87IEygAfLbJqZb4=" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); } } }