From 4378d48cb60b140c7c770c7dc25437bf393a97e9 Mon Sep 17 00:00:00 2001 From: Antonello Provenzano Date: Tue, 18 Apr 2023 23:10:22 +0200 Subject: [PATCH 1/8] Removing the receiver package using the HTTP contexts and redesigning the ASP.NET Core package --- Deveel.Webhooks.sln | 16 +- ...Deveel.Webhooks.Receiver.AspNetCore.csproj | 6 +- .../ServiceCollectionExtensions.cs | 27 ++ .../Webhooks/ApplicationBuilderExtensions.cs | 37 +++ .../Webhooks/DefaultWebhookReceiver.cs | 60 ----- .../Webhooks/HttpRequestExtensions.cs | 202 -------------- .../Webhooks/IWebhookHandler.cs | 8 + .../Webhooks/IWebhookJsonParser.cs | 10 + .../Webhooks/IWebhookReceiver.cs | 4 +- .../Webhooks/IWebhookRequestVerifier.cs | 10 + .../Webhooks/IWebhookSigner.cs | 9 + .../Webhooks/IWebhookSignerProvider.cs | 5 + .../Webhooks/IWebhookSigner_1.cs | 6 + .../Webhooks/Sha256WebhookSigner.cs | 33 +++ .../WebhookDelegatedReceiverMiddleware.cs | 21 ++ .../Webhooks/WebhookRceiverMiddleware.cs | 43 +++ .../Webhooks/WebhookReceiveResult.cs | 15 ++ .../Webhooks/WebhookReceiver.cs | 161 +++++++++++ .../Webhooks/WebhookReceiverBuilder.cs | 252 ++++++++++++++++++ .../WebhookReceiverBuilderExtensions.cs | 34 --- .../Webhooks/WebhookReceiverOptions.cs | 11 + .../WebhookRequestVerfierMiddleware.cs | 10 + .../Webhooks/WebhookSignatureLocation.cs | 0 .../Webhooks/WebhookSignatureOptions.cs | 13 + .../Deveel.Webhooks.Receiver.csproj | 36 --- .../System.Net.Http/HttpContentExtensions.cs | 133 --------- .../Webhooks/DefaultHttptWebhookReceiver.cs | 56 ---- .../Webhooks/HttpRequestMessageExtensions.cs | 100 ------- .../Webhooks/IHttpWebhookReceiver.cs | 24 -- .../Webhooks/IWebhookReceiverBuilder.cs | 23 -- .../Webhooks/ServiceCollectionExtensions.cs | 47 ---- .../Webhooks/WebhookJsonParser.cs | 40 --- .../Webhooks/WebhookReceiveOptions.cs | 33 --- ...kReceiverConfigurationBuilderExtensions.cs | 59 ---- .../Webhooks/WebhookSignatureValidator.cs | 50 ---- .../Webhooks/Sha256WebhookSigner.cs | 2 +- .../Deveel.Webhooks.Receiver.TestApi.csproj | 14 + .../Handlers/TestSignedWebhookHandler.cs | 19 ++ .../Handlers/TestWebhookHandler.cs | 23 ++ .../Handlers/TestWebhookReceiver.cs | 11 + .../Model/TestSignedWebhook.cs | 4 + .../Model/TestWebhook.cs | 13 + .../Program.cs | 56 ++++ .../Properties/launchSettings.json | 31 +++ .../appsettings.Development.json | 8 + .../appsettings.json | 16 ++ .../Deveel.Webhooks.Receiver.XUnit.csproj | 10 +- .../Webhooks/HttpWebhookReceiverTests.cs | 142 ---------- .../Webhooks/WebhookPayload.cs | 23 -- .../Webhooks/WebhookReceiveRequestTests.cs | 130 +++++++++ .../Webhooks/WebhookReceiverTests.cs | 176 ------------ 51 files changed, 1012 insertions(+), 1260 deletions(-) create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/ServiceCollectionExtensions.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs delete mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/DefaultWebhookReceiver.cs delete mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/HttpRequestExtensions.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookHandler.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookJsonParser.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSignerProvider.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner_1.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/Sha256WebhookSigner.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRceiverMiddleware.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs delete mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilderExtensions.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs rename src/{Deveel.Webhooks.Receiver => Deveel.Webhooks.Receiver.AspNetCore}/Webhooks/WebhookSignatureLocation.cs (100%) create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureOptions.cs delete mode 100644 src/Deveel.Webhooks.Receiver/Deveel.Webhooks.Receiver.csproj delete mode 100644 src/Deveel.Webhooks.Receiver/System.Net.Http/HttpContentExtensions.cs delete mode 100644 src/Deveel.Webhooks.Receiver/Webhooks/DefaultHttptWebhookReceiver.cs delete mode 100644 src/Deveel.Webhooks.Receiver/Webhooks/HttpRequestMessageExtensions.cs delete mode 100644 src/Deveel.Webhooks.Receiver/Webhooks/IHttpWebhookReceiver.cs delete mode 100644 src/Deveel.Webhooks.Receiver/Webhooks/IWebhookReceiverBuilder.cs delete mode 100644 src/Deveel.Webhooks.Receiver/Webhooks/ServiceCollectionExtensions.cs delete mode 100644 src/Deveel.Webhooks.Receiver/Webhooks/WebhookJsonParser.cs delete mode 100644 src/Deveel.Webhooks.Receiver/Webhooks/WebhookReceiveOptions.cs delete mode 100644 src/Deveel.Webhooks.Receiver/Webhooks/WebhookReceiverConfigurationBuilderExtensions.cs delete mode 100644 src/Deveel.Webhooks.Receiver/Webhooks/WebhookSignatureValidator.cs create mode 100644 test/Deveel.Webhooks.Receiver.TestApi/Deveel.Webhooks.Receiver.TestApi.csproj create mode 100644 test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestSignedWebhookHandler.cs create mode 100644 test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs create mode 100644 test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookReceiver.cs create mode 100644 test/Deveel.Webhooks.Receiver.TestApi/Model/TestSignedWebhook.cs create mode 100644 test/Deveel.Webhooks.Receiver.TestApi/Model/TestWebhook.cs create mode 100644 test/Deveel.Webhooks.Receiver.TestApi/Program.cs create mode 100644 test/Deveel.Webhooks.Receiver.TestApi/Properties/launchSettings.json create mode 100644 test/Deveel.Webhooks.Receiver.TestApi/appsettings.Development.json create mode 100644 test/Deveel.Webhooks.Receiver.TestApi/appsettings.json delete mode 100644 test/Deveel.Webhooks.Receiver.XUnit/Webhooks/HttpWebhookReceiverTests.cs delete mode 100644 test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookPayload.cs create mode 100644 test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs delete mode 100644 test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiverTests.cs diff --git a/Deveel.Webhooks.sln b/Deveel.Webhooks.sln index ac197cf..eca4034 100644 --- a/Deveel.Webhooks.sln +++ b/Deveel.Webhooks.sln @@ -7,8 +7,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks.Model", "sr EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks.XUnit", "test\Deveel.Events.Webhooks.XUnit\Deveel.Webhooks.XUnit.csproj", "{CF170566-4653-4E7A-AC54-5A1F7594B71F}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks.Receiver", "src\Deveel.Webhooks.Receiver\Deveel.Webhooks.Receiver.csproj", "{954A1901-CD16-4DD0-A6F3-D0D7E7F1B9A4}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks", "src\Deveel.Webhooks\Deveel.Webhooks.csproj", "{6435EBAE-5E0A-446D-B4E4-D75FE89DFC99}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks.Service", "src\Deveel.Webhooks.Service\Deveel.Webhooks.Service.csproj", "{D2A74CD1-202E-4F64-BAD8-88F37EB3805A}" @@ -23,7 +21,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{57F6404B-1FC EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{07F23FF6-2FE1-4072-BF37-9238E3750AA1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deveel.Webhooks.Receiver.XUnit", "test\Deveel.Webhooks.Receiver.XUnit\Deveel.Webhooks.Receiver.XUnit.csproj", "{4BC8323C-74F7-407A-8A5A-EA595B5C5585}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks.Receiver.XUnit", "test\Deveel.Webhooks.Receiver.XUnit\Deveel.Webhooks.Receiver.XUnit.csproj", "{4BC8323C-74F7-407A-8A5A-EA595B5C5585}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deveel.Webhooks.Receiver.TestApi", "test\Deveel.Webhooks.Receiver.TestApi\Deveel.Webhooks.Receiver.TestApi.csproj", "{CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -39,10 +39,6 @@ Global {CF170566-4653-4E7A-AC54-5A1F7594B71F}.Debug|Any CPU.Build.0 = Debug|Any CPU {CF170566-4653-4E7A-AC54-5A1F7594B71F}.Release|Any CPU.ActiveCfg = Release|Any CPU {CF170566-4653-4E7A-AC54-5A1F7594B71F}.Release|Any CPU.Build.0 = Release|Any CPU - {954A1901-CD16-4DD0-A6F3-D0D7E7F1B9A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {954A1901-CD16-4DD0-A6F3-D0D7E7F1B9A4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {954A1901-CD16-4DD0-A6F3-D0D7E7F1B9A4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {954A1901-CD16-4DD0-A6F3-D0D7E7F1B9A4}.Release|Any CPU.Build.0 = Release|Any CPU {6435EBAE-5E0A-446D-B4E4-D75FE89DFC99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6435EBAE-5E0A-446D-B4E4-D75FE89DFC99}.Debug|Any CPU.Build.0 = Debug|Any CPU {6435EBAE-5E0A-446D-B4E4-D75FE89DFC99}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -67,6 +63,10 @@ Global {4BC8323C-74F7-407A-8A5A-EA595B5C5585}.Debug|Any CPU.Build.0 = Debug|Any CPU {4BC8323C-74F7-407A-8A5A-EA595B5C5585}.Release|Any CPU.ActiveCfg = Release|Any CPU {4BC8323C-74F7-407A-8A5A-EA595B5C5585}.Release|Any CPU.Build.0 = Release|Any CPU + {CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -74,13 +74,13 @@ Global GlobalSection(NestedProjects) = preSolution {9D710920-2A08-466C-94F0-3FD90E752A20} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {CF170566-4653-4E7A-AC54-5A1F7594B71F} = {07F23FF6-2FE1-4072-BF37-9238E3750AA1} - {954A1901-CD16-4DD0-A6F3-D0D7E7F1B9A4} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {6435EBAE-5E0A-446D-B4E4-D75FE89DFC99} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {D2A74CD1-202E-4F64-BAD8-88F37EB3805A} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {9796303D-5EFF-4942-A563-6B53C4FE6904} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {6E1CC992-53F1-4536-96C5-751C8AFBD015} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {69EA8584-6336-4A62-BE73-DE04DC6EE8E1} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {4BC8323C-74F7-407A-8A5A-EA595B5C5585} = {07F23FF6-2FE1-4072-BF37-9238E3750AA1} + {CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F} = {07F23FF6-2FE1-4072-BF37-9238E3750AA1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E682A9F5-43D7-4D4C-82EA-953545B8F4DE} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj index 6c75d78..4f92079 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj @@ -19,6 +19,8 @@ + + @@ -33,8 +35,4 @@ - - - - diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/ServiceCollectionExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..b338707 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/ServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using System; + +using Deveel.Webhooks; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Deveel { + public static class ServiceCollectionExtensions { + public static WebhookReceiverBuilder AddWebhooks(this IServiceCollection services) + where TWebhook : class { + var builder= new WebhookReceiverBuilder(typeof(TWebhook), services); + + services.TryAddSingleton(builder); + + return builder; + } + + public static IServiceCollection AddWebhooks(this IServiceCollection services, Action configure) + where TWebhook : class { + var builder = services.AddWebhooks(); + configure?.Invoke(builder); + + return services; + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..eaf7afa --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace Deveel.Webhooks { + public static class ApplicationBuilderExtensions { + public static IApplicationBuilder UseWebhookReceiver(this IApplicationBuilder app, string path) + where TWebhook : class { + return app.MapWhen( + context => context.Request.Method == "POST" && context.Request.Path.Equals(path), + builder => builder.UseMiddleware>() + ); + } + + public static IApplicationBuilder UseWebhookVerifier(this IApplicationBuilder app, string method, string path) + where TWebhook : class + => app.MapWhen( + context => context.Request.Method == method && context.Request.Path.Equals(path), + builder => builder.UseMiddleware>() + ); + + public static IApplicationBuilder UseWebhookVerfier(this IApplicationBuilder app, string path) + where TWebhook : class + => app.UseWebhookVerifier("GET", path); + + public static IApplicationBuilder UseWebhookReceiver(this IApplicationBuilder app, string path, Func receiver) + where TWebhook : class { + return app.MapWhen( + context => context.Request.Method == "POST" && context.Request.Path.Equals(path), + builder => builder.UseMiddleware>(receiver) + ); + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/DefaultWebhookReceiver.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/DefaultWebhookReceiver.cs deleted file mode 100644 index 409c2f2..0000000 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/DefaultWebhookReceiver.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; - -using Newtonsoft.Json.Linq; - -namespace Deveel.Webhooks { - public class DefaultWebhookReceiver : IWebhookReceiver where T : class { - private readonly Action afterRead; - - public DefaultWebhookReceiver(IOptions options, Action afterRead) - : this(options?.Value, afterRead) { - } - - public DefaultWebhookReceiver(IOptions options) - : this(options, null) { - } - - public DefaultWebhookReceiver(WebhookReceiveOptions options, Action afterRead) { - Options = options; - this.afterRead = afterRead; - } - - public DefaultWebhookReceiver(WebhookReceiveOptions options) - : this(options, null) { - } - - public DefaultWebhookReceiver() - : this(new WebhookReceiveOptions()) { - } - - protected WebhookReceiveOptions Options { get; } - - protected virtual void OnAfterRead(JObject json, T obj) { - afterRead?.Invoke(json, obj); - } - - public Task ReceiveAsync(HttpRequest request, CancellationToken cancellationToken) { - return request.GetWebhookAsync(Options, OnAfterRead, cancellationToken); - } - } -} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/HttpRequestExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/HttpRequestExtensions.cs deleted file mode 100644 index 670da9f..0000000 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/HttpRequestExtensions.cs +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Threading.Tasks; -using System.Threading; -using System.Web; -using System.Xml.Linq; - -using Microsoft.AspNetCore.Http; - -using Newtonsoft.Json.Linq; -using System.IO; -using System.Text; -using System.Linq; -using Newtonsoft.Json; -using System.Net.Http; - -namespace Deveel.Webhooks { - public static class HttpRequestExtensions { - public static string GetCharset(this HttpRequest request) { - var contentType = request.ContentType; - if (String.IsNullOrWhiteSpace(contentType)) - return null; - - var parts = contentType.Split(';', StringSplitOptions.RemoveEmptyEntries); - var charsetPart = parts.FirstOrDefault(x => x.StartsWith("chartset")); - if (String.IsNullOrWhiteSpace(charsetPart)) - return null; - - var index = charsetPart.IndexOf('='); - return charsetPart.Substring(index + 1); - } - - public static string GetMediaType(this HttpRequest request) { - var contentType = request.ContentType; - if (String.IsNullOrWhiteSpace(contentType)) - return null; - - int sepIndex = -1; - if ((sepIndex = contentType.IndexOf(';')) != -1) { - contentType = contentType.Substring(0, sepIndex); - } - - return contentType; - } - - public static Task ReadAsStringAsync(this HttpRequest request) { - var encoding = Encoding.UTF8; - var charset = request.GetCharset(); - - if (!String.IsNullOrWhiteSpace(charset)) { - encoding = Encoding.GetEncoding(charset); - } - - using (var reader = new StreamReader(request.Body, encoding)) { - return reader.ReadToEndAsync(); - } - } - - public static async Task ReadAsObjectAsync(this HttpRequest request, JsonSerializerSettings serializerSettings, Action afterRead, CancellationToken cancellationToken = default) { - serializerSettings = serializerSettings ?? new JsonSerializerSettings(); - - var obj = await request.ReadAsJsonObjectAsync(cancellationToken); - - T result; - - try { - result = obj.ToObject(JsonSerializer.Create(serializerSettings)); - } catch (Exception ex) { - throw new FormatException("The webhook JSON format is invalid", ex); - } - - afterRead?.Invoke(obj, result); - - return result; - - } - - public static async Task ReadAsJsonObjectAsync(this HttpRequest request, CancellationToken cancellationToken = default) { - var token = await request.ReadAsJsonAsync(cancellationToken); - - if (token.Type != JTokenType.Object) - throw new FormatException("The json request is invalid"); - - return (JObject)token; - } - - public static async Task ReadAsJsonAsync(this HttpRequest request, CancellationToken cancellationToken = default) { - if (request.Headers == null) - throw new FormatException("Missing content headers"); - - - var mediaType = request.GetMediaType(); - - if (String.IsNullOrWhiteSpace(mediaType)) - throw new FormatException("Content-type of the request is missing"); - - if (mediaType != "application/json" && - mediaType != "text/json") - throw new NotSupportedException("Only JSON webhooks supported at this moment"); - - var encoding = Encoding.UTF8; - var charset = request.GetCharset(); - - if (!String.IsNullOrWhiteSpace(charset)) { - encoding = Encoding.GetEncoding(charset); - } - - JToken token; - - using (var textReader = new StreamReader(request.Body, encoding)) { - using (var jsonReader = new JsonTextReader(textReader)) { - token = await JToken.ReadFromAsync(jsonReader, cancellationToken); - } - } - - return token; - } - - - public static Task GetWebhookAsync(this HttpRequest request, Action afterRead, CancellationToken cancellationToken = default) - where T : class - => request.GetWebhookAsync(new WebhookReceiveOptions(), afterRead, cancellationToken); - - public static Task GetWebhookAsync(this HttpRequest request, WebhookReceiveOptions options, CancellationToken cancellationToken = default) - where T : class - => request.GetWebhookAsync(options, null, cancellationToken); - - public static Task GetWebhookAsync(this HttpRequest request, CancellationToken cancellationToken) - where T : class - => request.GetWebhookAsync(new WebhookReceiveOptions(), cancellationToken); - - public static Task GetWebhookAsync(this HttpRequest request) - where T : class - => request.GetWebhookAsync(default(CancellationToken)); - - public static async Task GetWebhookAsync(this HttpRequest request, WebhookReceiveOptions options, Action afterRead, CancellationToken cancellationToken) - where T : class { - if (options != null && options.ValidateSignature) { - var content = await request.ReadAsStringAsync(); - - if (!request.IsSignatureValid(content, options)) - throw new ArgumentException("The signature of the webhook is invalid"); - - return await WebhookJsonParser.ParseAsync(content, options.JsonSerializerSettings, afterRead, cancellationToken); - } - - return await request.ReadAsObjectAsync(options?.JsonSerializerSettings, afterRead, cancellationToken); - } - - private static bool IsSignatureValid(this HttpRequest request, string content, WebhookReceiveOptions options) { - string signature; - string algorithm = null; - - switch (options.SignatureLocation) { - case WebhookSignatureLocation.Header: - if (!request.Headers.TryGetValue(options.SignatureHeaderName, out var headerValue)) - return false; - - signature = headerValue.SingleOrDefault(); - - if (!string.IsNullOrEmpty(signature)) { - if (signature.StartsWith("sha256=")) { - signature = signature.Substring("sha256=".Length); - algorithm = "sha256"; - } - } - - break; - case WebhookSignatureLocation.QueryString: - if (request.Query.Count == 0) - return false; - - signature = request.Query[options.SignatureQueryStringKey]; - algorithm = request.Query["sig_alg"]; - - break; - default: - // should never happen - throw new NotSupportedException(); - } - - if (string.IsNullOrWhiteSpace(signature)) - return false; - - return WebhookSignatureValidator.IsValid(algorithm, content, options.Secret, signature); - } - - } -} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookHandler.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookHandler.cs new file mode 100644 index 0000000..0159cb0 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookHandler.cs @@ -0,0 +1,8 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Deveel.Webhooks { + public interface IWebhookHandler where TWebhook : class { + Task HandleAsync(TWebhook webhook, CancellationToken cancellationToken = default); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookJsonParser.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookJsonParser.cs new file mode 100644 index 0000000..d975374 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookJsonParser.cs @@ -0,0 +1,10 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Deveel.Webhooks { + public interface IWebhookJsonParser { + Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookReceiver.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookReceiver.cs index 7819098..2d4cb09 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookReceiver.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookReceiver.cs @@ -19,7 +19,7 @@ using Microsoft.AspNetCore.Http; namespace Deveel.Webhooks { - public interface IWebhookReceiver where T : class { - Task ReceiveAsync(HttpRequest request, CancellationToken cancellationToken); + public interface IWebhookReceiver where TWebhook : class { + Task> ReceiveAsync(HttpRequest request, CancellationToken cancellationToken); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs new file mode 100644 index 0000000..c7422d1 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Http; + +namespace Deveel.Webhooks { + public interface IWebhookRequestVerifier { + Task VerifyRequestAsync(HttpContext context); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner.cs new file mode 100644 index 0000000..dcf5be5 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner.cs @@ -0,0 +1,9 @@ +using System; + +namespace Deveel.Webhooks { + public interface IWebhookSigner { + string[] Algorithms { get; } + + string SignWebhook(string jsonBody, string secret); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSignerProvider.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSignerProvider.cs new file mode 100644 index 0000000..f87b71f --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSignerProvider.cs @@ -0,0 +1,5 @@ +namespace Deveel.Webhooks { + public interface IWebhookSignerProvider { + IWebhookSigner GetSigner(string algorithm); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner_1.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner_1.cs new file mode 100644 index 0000000..2f5ce59 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner_1.cs @@ -0,0 +1,6 @@ +using System; + +namespace Deveel.Webhooks { + public interface IWebhookSigner : IWebhookSigner where TWebhook : class { + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/Sha256WebhookSigner.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/Sha256WebhookSigner.cs new file mode 100644 index 0000000..3f790b2 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/Sha256WebhookSigner.cs @@ -0,0 +1,33 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Deveel.Webhooks { + public class Sha256WebhookSigner : IWebhookSigner { + public virtual string[] Algorithms => new[] { "sha256", "sha-256" }; + + protected virtual byte[] ComputeHash(string jsonBody, string secret) { + if (string.IsNullOrWhiteSpace(secret)) + throw new ArgumentException($"'{nameof(secret)}' cannot be null or whitespace.", nameof(secret)); + + var key = Encoding.UTF8.GetBytes(secret); + using var sha256 = new HMACSHA256(key); + + return sha256.ComputeHash(Encoding.UTF8.GetBytes(jsonBody)); + } + + protected virtual string GetSignatureString(byte[] hash) { + return $"{Algorithms[0]}={Convert.ToBase64String(hash)}"; + } + + public virtual string SignWebhook(string jsonBody, string secret) { + if (string.IsNullOrWhiteSpace(jsonBody)) + throw new ArgumentException($"'{nameof(jsonBody)}' cannot be null or whitespace.", nameof(jsonBody)); + if (string.IsNullOrWhiteSpace(secret)) + throw new ArgumentException($"'{nameof(secret)}' cannot be null or whitespace.", nameof(secret)); + + var hash = ComputeHash(jsonBody, secret); + return GetSignatureString(hash); + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs new file mode 100644 index 0000000..5ec0c25 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Http; + +namespace Deveel.Webhooks { + class WebhookDelegatedReceiverMiddleware { + private readonly RequestDelegate next; + private readonly Func receiver; + + public WebhookDelegatedReceiverMiddleware(RequestDelegate next, Func receiver) { + this.next = next; + this.receiver = receiver; + } + + public Task InvokeAsync(HttpContext context) { + return Task.CompletedTask; + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRceiverMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRceiverMiddleware.cs new file mode 100644 index 0000000..f6aba59 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRceiverMiddleware.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Http; + +namespace Deveel.Webhooks { + class WebhookRceiverMiddleware : IMiddleware where TWebhook : class { + private readonly IEnumerable> handlers; + private readonly IWebhookReceiver receiver; + + public WebhookRceiverMiddleware(IWebhookReceiver receiver, IEnumerable> handlers) { + this.receiver = receiver; + this.handlers = handlers; + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) { + try { + var result = await receiver.ReceiveAsync(context.Request, context.RequestAborted); + + if (result.SignatureValid != null && !result.SignatureValid.Value) { + // TODO: get this from the configuration + context.Response.StatusCode = 400; + } else if (result.Webhook == null) { + context.Response.StatusCode = 400; + } else if (handlers != null) { + foreach (var handler in handlers) { + await handler.HandleAsync(result.Webhook, context.RequestAborted); + } + } else { + await next(context); + } + } catch (Exception ex) { + // TODO: log this error ... + + context.Response.StatusCode = 500; + // TODO: should we emit anything here? + } + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs new file mode 100644 index 0000000..94dfda5 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs @@ -0,0 +1,15 @@ +namespace Deveel.Webhooks { + public readonly struct WebhookReceiveResult where TWebhook : class { + public WebhookReceiveResult(TWebhook webhook, bool? signatureValid) : this() { + Webhook = webhook; + SignatureValid = signatureValid; + } + + public TWebhook Webhook { get; } + + public bool? SignatureValid { get; } + + public static implicit operator WebhookReceiveResult(TWebhook webhook) + => new WebhookReceiveResult(webhook, null); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs new file mode 100644 index 0000000..5e64cb0 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Deveel.Webhooks { + public partial class WebhookReceiver : IWebhookReceiver + where TWebhook : class { + private readonly IWebhookSignerProvider signerProvider; + + public WebhookReceiver(IOptions> options, + IWebhookSignerProvider signerProvider = null, + IWebhookJsonParser jsonParser = null) + : this(options.Value, jsonParser) { + this.signerProvider = signerProvider; + } + + protected WebhookReceiver(WebhookReceiverOptions receiverOptions, IWebhookJsonParser jsonParser) { + ReceiverOptions = receiverOptions ?? throw new ArgumentNullException(nameof(receiverOptions)); + JsonParser = jsonParser; + } + + protected WebhookReceiverOptions ReceiverOptions { get; } + + protected IWebhookJsonParser JsonParser { get; } + + protected virtual IWebhookSigner GetSigner(string algorithm) { + return signerProvider?.GetSigner(algorithm); + } + + protected virtual string SignWebhook(string jsonBody, string algorithm, string secret) { + var signer = GetSigner(algorithm); + if (signer == null) + return null; + + return signer.SignWebhook(jsonBody, secret); + } + + protected virtual async Task ParseJsonAsync(string jsonBody, CancellationToken cancellationToken) { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonBody)); + return await ParseJsonAsync(stream, cancellationToken); + } + + protected virtual async Task ParseJsonAsync(Stream utf8Stream, CancellationToken cancellationToken) { + if (JsonParser == null) + throw new NotSupportedException("The JSON parser was not provided"); + + return await JsonParser.ParseWebhookAsync(utf8Stream, cancellationToken); + } + + private int InvalidSignatureStatusCode() => ReceiverOptions.Signature?.InvalidStatusCode ?? 400; + + private bool ValidateSignature() + => (ReceiverOptions.VerifySignature ?? false) && + ReceiverOptions.Signature != null && + !String.IsNullOrWhiteSpace(ReceiverOptions.Signature.ParameterName) && + !String.IsNullOrWhiteSpace(ReceiverOptions.Signature.Secret) && + !String.IsNullOrWhiteSpace(ReceiverOptions.Signature.Algorithm); + + protected virtual bool TryGetSignature(HttpRequest request, out string signature) { + if (!ValidateSignature()) { + signature = null; + return false; + } + + if (ReceiverOptions.Signature.Location == WebhookSignatureLocation.Header) { + if (!request.Headers.TryGetValue(ReceiverOptions.Signature.ParameterName, out var header)) { + signature = null; + return false; + } + + signature = header.ToString(); + return true; + } else if (ReceiverOptions.Signature.Location == WebhookSignatureLocation.QueryString) { + if (!request.Query.TryGetValue(ReceiverOptions.Signature.ParameterName, out var value)) { + signature = null; + return false; + } + + signature = value.ToString(); + return true; + } + + signature = null; + return false; + } + + protected virtual bool IsSignatureValid(string signature, string algorithm, string jsonBody) { + if (!(ReceiverOptions.VerifySignature ?? false)) + return true; + + var computedSignature = SignWebhook(jsonBody, algorithm, ReceiverOptions.Signature.Secret); + if (String.IsNullOrWhiteSpace(computedSignature)) + return false; + + return String.Equals(computedSignature, signature, StringComparison.OrdinalIgnoreCase); + } + + //private bool ValidateSha256Signature(string signature, string jsonBody, string secret) { + // var key = Encoding.ASCII.GetBytes(secret); + + // using var sha256 = new HMACSHA256(key); + // var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(jsonBody)); + // var computedSignature = BitConverter.ToString(hash); + + // return String.Equals(signature, computedSignature, StringComparison.Ordinal); + //} + + protected async Task TryValidateWebhook(HttpRequest request) { + using var reader = new StreamReader(request.Body, Encoding.UTF8); + var jsonBody = await reader.ReadToEndAsync(); + + if (!ValidateSignature() || + !TryGetSignature(request, out var signature)) + return new ValidateResult(jsonBody, false, null); + + var isValid = IsSignatureValid(signature, ReceiverOptions.Signature.Algorithm, jsonBody); + + return new ValidateResult(jsonBody, true, isValid); + } + + public virtual async Task> ReceiveAsync(HttpRequest request, CancellationToken cancellationToken) { + if (ValidateSignature()) { + var result = await TryValidateWebhook(request); + + if (result.SignatureValidated && !(result.IsValid ?? false)) { + return new WebhookReceiveResult(null, false); + } else if ((result.SignatureValidated && (result.IsValid ?? false)) || + !result.SignatureValidated) { + var signatureValid = result.SignatureValidated && (result.IsValid ?? false); + var webhook = await ParseJsonAsync(result.JsonBody, cancellationToken); + return new WebhookReceiveResult(webhook, signatureValid); + } else { + throw new NotSupportedException(); + } + } else { + return await ParseJsonAsync(request.Body, cancellationToken); + } + } + + protected readonly struct ValidateResult { + public bool SignatureValidated { get; } + + public bool? IsValid { get; } + + public string JsonBody { get; } + + public ValidateResult(string jsonBody, bool validated, bool? isValid) : this() { + JsonBody = jsonBody; + SignatureValidated = validated; + IsValid = isValid; + } + } + + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs new file mode 100644 index 0000000..0eb9576 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Deveel.Webhooks { + public sealed class WebhookReceiverBuilder { + public WebhookReceiverBuilder(Type webhookType, IServiceCollection services) { + if (webhookType == null) + throw new ArgumentNullException(nameof(webhookType)); + + if (!webhookType.IsClass || webhookType.IsAbstract) + throw new ArgumentException("The webhook type must be a non-abstract class"); + + WebhookType = webhookType; + Services = services ?? throw new ArgumentNullException(nameof(services)); + + RegisterReceiverMiddleware(); + RegisterVerifierMiddleware(); + RegisterDefaultReceiver(); + } + + public Type WebhookType { get; } + + public IServiceCollection Services { get; } + + private void RegisterReceiverMiddleware() { + var middlewareType = typeof(WebhookRceiverMiddleware<>).MakeGenericType(WebhookType); + Services.AddScoped(middlewareType); + } + + private void RegisterVerifierMiddleware() { + var middlewareType = typeof(WebhookRequestVerfierMiddleware<>).MakeGenericType(WebhookType); + Services.AddScoped(middlewareType); + } + + private void RegisterDefaultReceiver() { + var receiverType = typeof(IWebhookReceiver<>).MakeGenericType(WebhookType); + var defaultReceiverType = typeof(WebhookReceiver<>).MakeGenericType(WebhookType); + + Services.TryAddScoped(receiverType, defaultReceiverType); + Services.TryAddScoped(defaultReceiverType, defaultReceiverType); + } + + public WebhookReceiverBuilder UseReceiver() { + var receiverType = typeof(IWebhookReceiver<>).MakeGenericType(WebhookType); + if (!receiverType.IsAssignableFrom(typeof(TReceiver))) + throw new ArgumentException($"The type '{typeof(TReceiver)}' must be assignable from '{receiverType}'"); + + Services.RemoveAll(receiverType); + Services.AddScoped(receiverType, typeof(TReceiver)); + + if (typeof(TReceiver).IsClass && !typeof(TReceiver).IsAbstract) + Services.AddScoped(typeof(TReceiver), typeof(TReceiver)); + + return this; + } + + public WebhookReceiverBuilder AddHandler() { + var handlerType = typeof(IWebhookHandler<>).MakeGenericType(WebhookType); + if (!handlerType.IsAssignableFrom(typeof(THandler))) + throw new ArgumentException($"The type '{typeof(THandler)}' must be assignable from '{handlerType}'"); + + Services.AddScoped(handlerType, typeof(THandler)); + + if (typeof(THandler).IsClass && !typeof(THandler).IsAbstract) + Services.AddScoped(typeof(THandler), typeof(THandler)); + + return this; + } + + public WebhookReceiverBuilder ConfigureOptions(string sectionName) where TOptions : class { + var optionType = typeof(WebhookReceiverOptions<>).MakeGenericType(WebhookType); + if (!optionType.IsAssignableFrom(typeof(TOptions))) + throw new ArgumentException($"The options type '{typeof(TOptions)}' is not assignable from '{optionType}'"); + + // TODO: Validate the configured options + Services.AddOptions() + .BindConfiguration(sectionName); + + return this; + } + + public WebhookReceiverBuilder ConfigureOptions(Action configure) where TOptions : class { + var optionType = typeof(WebhookReceiverOptions<>).MakeGenericType(WebhookType); + if (!optionType.IsAssignableFrom(typeof(TOptions))) + throw new ArgumentException($"The options type '{typeof(TOptions)}' is not assignable to '{optionType}'"); + + // TODO: Validate the configured options + Services.AddOptions() + .Configure(configure); + + return this; + } + + public WebhookReceiverBuilder UseJsonParser(ServiceLifetime lifetime = ServiceLifetime.Singleton) { + var parserType = typeof(IWebhookJsonParser<>).MakeGenericType(WebhookType); + + if (!parserType.IsAssignableFrom(typeof(TParser))) + throw new ArgumentException($"The type '{typeof(TParser)}' is not assignable to '{parserType}'"); + + Services.RemoveAll(parserType); + Services.Add(new ServiceDescriptor(parserType, typeof(TParser), lifetime)); + + if (typeof(TParser).IsClass && !typeof(TParser).IsAbstract) + Services.Add(new ServiceDescriptor(typeof(TParser), typeof(TParser), lifetime)); + + return this; + } + + public WebhookReceiverBuilder UseJsonParser(Func> parser) + where TWebhook : class { + if (typeof(TWebhook) != WebhookType) + throw new ArgumentException($"The parser must return webhooks of type '{WebhookType}'"); + + var parserType = typeof(IWebhookJsonParser<>).MakeGenericType(WebhookType); + + Services.RemoveAll(parserType); + Services.AddSingleton(parserType, new DelegatedJsonParser(parser)); + + return this; + } + + public WebhookReceiverBuilder UseJsonParser(Func parser) + where TWebhook : class { + if (typeof(TWebhook) != WebhookType) + throw new ArgumentException($"The parser must return webhooks of type '{WebhookType}'"); + + var parserType = typeof(IWebhookJsonParser<>).MakeGenericType(WebhookType); + + Services.RemoveAll(parserType); + Services.AddSingleton(parserType, new DelegatedJsonParser(parser)); + + return this; + } + + public WebhookReceiverBuilder UseSigner() where TSigner : class, IWebhookSigner { + var signerType = typeof(IWebhookSigner<>).MakeGenericType(WebhookType); + + if (!signerType.IsAssignableFrom(typeof(TSigner))) { + var signer = (IWebhookSigner) Activator.CreateInstance(typeof(TSigner)); + var wrapperType = typeof(WebhookSignerWrapper<>).MakeGenericType(WebhookType); + var wrapper = Activator.CreateInstance(wrapperType, new[] { signer }); + Services.AddSingleton(signerType, wrapper); + } else { + Services.AddSingleton(signerType, typeof(TSigner)); + } + + var providerType = typeof(IWebhookSignerProvider<>).MakeGenericType(WebhookType); + var defaultProviderType = typeof(DefaultWebhookSignerProvider<>).MakeGenericType(WebhookType); + Services.TryAddSingleton(providerType, defaultProviderType); + + return this; + } + + public WebhookReceiverBuilder UseSigner(TSigner provider) + where TSigner : class, IWebhookSigner { + var signerType = typeof(IWebhookSigner<>).MakeGenericType(WebhookType); + + if (!signerType.IsAssignableFrom(typeof(TSigner))) { + var wrapperType = typeof(WebhookSignerWrapper<>).MakeGenericType(WebhookType); + var wrapper = Activator.CreateInstance(wrapperType, new[] { provider }); + Services.AddSingleton(signerType, wrapper); + } else { + Services.AddSingleton(signerType, typeof(TSigner)); + } + + var providerType = typeof(IWebhookSignerProvider<>).MakeGenericType(WebhookType); + var defaultProviderType = typeof(DefaultWebhookSignerProvider<>).MakeGenericType(WebhookType); + Services.TryAddSingleton(providerType, defaultProviderType); + + return this; + } + + class DefaultWebhookSignerProvider : IWebhookSignerProvider + where TWebhook : class { + private readonly IDictionary signers; + + public DefaultWebhookSignerProvider(IEnumerable> signers) { + this.signers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (signers != null) { + foreach (var signer in signers) { + foreach (var alg in signer.Algorithms) { + this.signers[alg] = signer; + } + } + } + } + + public IWebhookSigner GetSigner(string algorithm) { + if (!signers.TryGetValue(algorithm, out var signer)) + return null; + + return signer; + } + } + + #region WebhookSignatureProviderWrapper + + class WebhookSignerWrapper : IWebhookSigner where TWebhook : class { + private readonly IWebhookSigner signer; + + public WebhookSignerWrapper(IWebhookSigner signer) { + this.signer = signer; + } + + public string[] Algorithms => signer.Algorithms; + + public string SignWebhook(string jsonBody, string secret) => signer.SignWebhook(jsonBody, secret); + } + + #endregion + + #region DelegatedJsonParser + + class DelegatedJsonParser : IWebhookJsonParser where TWebhook : class { + private readonly Func> streamParser; + private readonly Func syncStringParser; + + public DelegatedJsonParser(Func syncStringParser) { + this.syncStringParser = syncStringParser; + } + + public DelegatedJsonParser(Func> parser) { + this.streamParser = parser; + } + + public async Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default) { + if (streamParser != null) { + return await streamParser(utf8Stream, cancellationToken); + } else if (syncStringParser != null) { + using var reader = new StreamReader(utf8Stream, Encoding.UTF8); + var json = await reader.ReadToEndAsync(); + + return syncStringParser(json); + } + + throw new NotSupportedException(); + } + } + + #endregion + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilderExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilderExtensions.cs deleted file mode 100644 index b82f7f0..0000000 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilderExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; - -using Microsoft.Extensions.DependencyInjection; - -namespace Deveel.Webhooks { - public static class WebhookReceiverBuilderExtensions { - public static IWebhookReceiverBuilder AddReceiver(this IWebhookReceiverBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton) - where TReceiver : class, IWebhookReceiver - where TWebhook : class { - return builder.ConfigureServices(services => { - services.Add(new ServiceDescriptor(typeof(IWebhookReceiver), typeof(TReceiver), lifetime)); - services.Add(new ServiceDescriptor(typeof(TReceiver), typeof(TReceiver), lifetime)); - }); - } - - public static IWebhookReceiverBuilder AddReceiver(this IWebhookReceiverBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton) - where TWebhook : class - => builder.AddReceiver, TWebhook>(lifetime); - } -} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs new file mode 100644 index 0000000..6883725 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs @@ -0,0 +1,11 @@ +using System; + +namespace Deveel.Webhooks { + public class WebhookReceiverOptions { + public bool? VerifySignature { get; set; } + + public WebhookSignatureOptions Signature { get; set; } = new WebhookSignatureOptions(); + + public int? ResponseStatusCode { get; set; } = 201; + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs new file mode 100644 index 0000000..6f9f020 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Http; + +namespace Deveel.Webhooks { + class WebhookRequestVerfierMiddleware : IMiddleware where TWebhook : class { + public Task InvokeAsync(HttpContext context, RequestDelegate next) => throw new NotImplementedException(); + } +} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookSignatureLocation.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureLocation.cs similarity index 100% rename from src/Deveel.Webhooks.Receiver/Webhooks/WebhookSignatureLocation.cs rename to src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureLocation.cs diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureOptions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureOptions.cs new file mode 100644 index 0000000..ff65bb0 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureOptions.cs @@ -0,0 +1,13 @@ +namespace Deveel.Webhooks { + public class WebhookSignatureOptions { + public WebhookSignatureLocation Location { get; set; } = WebhookSignatureLocation.Header; + + public string ParameterName { get; set; } + + public string Algorithm { get; set; } = "SHA-256"; + + public string Secret { get; set; } + + public int? InvalidStatusCode { get; set; } = 400; + } +} diff --git a/src/Deveel.Webhooks.Receiver/Deveel.Webhooks.Receiver.csproj b/src/Deveel.Webhooks.Receiver/Deveel.Webhooks.Receiver.csproj deleted file mode 100644 index 555eb50..0000000 --- a/src/Deveel.Webhooks.Receiver/Deveel.Webhooks.Receiver.csproj +++ /dev/null @@ -1,36 +0,0 @@ - - - - net6.0 - 1.1.6 - Deveel - true - Antonello Provenzano - Deveel - Helper classes to receive webhooks. Although it should support other providers, this is primarly intended to support the webhooks produced through the Deveel framework - (C) 2020-2021 Deveel - LICENSE - deveel-logo.png - - https://github.com/deveel/deveel.webhooks - git - webhook receiver receivers http - - - - - - - - - - True - - - - True - - - - - diff --git a/src/Deveel.Webhooks.Receiver/System.Net.Http/HttpContentExtensions.cs b/src/Deveel.Webhooks.Receiver/System.Net.Http/HttpContentExtensions.cs deleted file mode 100644 index 1ef6a7c..0000000 --- a/src/Deveel.Webhooks.Receiver/System.Net.Http/HttpContentExtensions.cs +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.IO; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace System.Net.Http { - public static class HttpContentExtensions { - public static Task ReadAsObjectAsync(this HttpContent content, Action afterRead, CancellationToken cancellationToken) - where T : class - => content.ReadAsObjectAsync(new JsonSerializerSettings(), afterRead, cancellationToken); - - public static Task ReadAsObjectAsync(this HttpContent content, JsonSerializerSettings serializerSettings, CancellationToken cancellationToken) - where T : class - => content.ReadAsObjectAsync(serializerSettings, null, cancellationToken); - - public static Task ReadAsObjectAsync(this HttpContent content, JsonSerializerSettings serializerSettings, Action afterRead) - where T : class - => content.ReadAsObjectAsync(serializerSettings, afterRead, default); - - public static Task ReadAsObjectAsync(this HttpContent content, JsonSerializerSettings serializerSettings) - where T : class - => content.ReadAsObjectAsync(serializerSettings, default(CancellationToken)); - - public static Task ReadAsObjectAsync(this HttpContent content, CancellationToken cancellationToken) - where T : class - => content.ReadAsObjectAsync(new JsonSerializerSettings(), cancellationToken); - - public static async Task ReadAsObjectAsync(this HttpContent content, JsonSerializerSettings serializerSettings, Action afterRead, CancellationToken cancellationToken) - where T : class { - - serializerSettings = serializerSettings ?? new JsonSerializerSettings(); - - var obj = await content.ReadAsJsonObjectAsync(cancellationToken); - - T result; - - try { - result = obj.ToObject(JsonSerializer.Create(serializerSettings)); - } catch (Exception ex) { - throw new FormatException("The webhook JSON format is invalid", ex); - } - - afterRead?.Invoke(obj, result); - - return result; - } - - public static async Task ReadAsJsonObjectAsync(this HttpContent content, CancellationToken cancellationToken = default) { - var token = await content.ReadAsJsonAsync(cancellationToken); - - if (token.Type != JTokenType.Object) - throw new FormatException("The json request is invalid"); - - return (JObject)token; - } - - public static async Task ReadAsJsonAsync(this HttpContent content, CancellationToken cancellationToken = default) { - if (content.Headers == null) - throw new FormatException("Missing content headers"); - if (content.Headers.ContentType == null) - throw new FormatException("Content-type of the request is missing"); - - if (content.Headers.ContentType.MediaType != "application/json" && - content.Headers.ContentType.MediaType != "text/json") - throw new NotSupportedException("Only JSON webhooks supported at this moment"); - - // TODO: retrieve the encoding from the headers - - JToken token; - - using (var stream = await content.ReadAsStreamAsync()) { - using (var textReader = new StreamReader(stream, Encoding.UTF8)) { - using (var jsonReader = new JsonTextReader(textReader)) { - token = await JToken.ReadFromAsync(jsonReader, cancellationToken); - } - } - } - - return token; - } - - public static Task ReadAsObjectAsync(this HttpContent content, Type webhookType, JsonSerializerSettings serializerSettings, Action afterRead) - => content.ReadAsObjectAsync(webhookType, serializerSettings, afterRead, default); - - public static Task ReadAsObjectAsync(this HttpContent content, Type webhookType, JsonSerializerSettings serializerSettings, CancellationToken cancellationToken) - => content.ReadAsObjectAsync(webhookType, serializerSettings, null, cancellationToken); - - public static Task ReadAsObjectAsync(this HttpContent content, Type webhookType, CancellationToken cancellationToken) - => content.ReadAsObjectAsync(webhookType, new JsonSerializerSettings(), cancellationToken); - - public static Task ReadAsObjectAsync(this HttpContent content, Type webhookType, Action afterRead, CancellationToken cancellationToken) - => content.ReadAsObjectAsync(webhookType, new JsonSerializerSettings(), afterRead, cancellationToken); - - public static Task ReadAsObjectAsync(this HttpContent content, Type webhookType, Action afterRead) - => content.ReadAsObjectAsync(webhookType, afterRead, default); - - public static async Task ReadAsObjectAsync(this HttpContent content, Type webhookType, JsonSerializerSettings serializerSettings, Action afterRead, CancellationToken cancellationToken) { - serializerSettings = serializerSettings ?? new JsonSerializerSettings(); - - var obj = await content.ReadAsJsonObjectAsync(cancellationToken); - - object result; - - try { - result = obj.ToObject(webhookType, JsonSerializer.Create(serializerSettings)); - } catch (Exception ex) { - throw new FormatException("The webhook JSON format is invalid", ex); - } - - afterRead?.Invoke(obj, result); - - return result; - } - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/DefaultHttptWebhookReceiver.cs b/src/Deveel.Webhooks.Receiver/Webhooks/DefaultHttptWebhookReceiver.cs deleted file mode 100644 index 5776cbd..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/DefaultHttptWebhookReceiver.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -using Microsoft.Extensions.Options; - -using Newtonsoft.Json.Linq; - -namespace Deveel.Webhooks { - public class DefaultHttptWebhookReceiver : IHttpWebhookReceiver where T : class { - private readonly WebhookReceiveOptions options; - private readonly Action afterRead; - - public DefaultHttptWebhookReceiver(IOptions options) - : this(options, null) { - } - - public DefaultHttptWebhookReceiver(IOptions options, Action afterRead) - : this(options?.Value, afterRead) { - } - - public DefaultHttptWebhookReceiver(WebhookReceiveOptions options, Action afterRead = null) { - this.options = options; - this.afterRead = afterRead; - } - - public DefaultHttptWebhookReceiver() - : this(new WebhookReceiveOptions()) { - } - - protected virtual void OnAfterRead(JObject json, T obj) { - afterRead?.Invoke(json, obj); - } - - protected WebhookReceiveOptions Options { get; } - - public virtual Task ReceiveAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - return request.GetWebhookAsync(Options, OnAfterRead, cancellationToken); - } - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/HttpRequestMessageExtensions.cs b/src/Deveel.Webhooks.Receiver/Webhooks/HttpRequestMessageExtensions.cs deleted file mode 100644 index 3ea8f75..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/HttpRequestMessageExtensions.cs +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using System.Web; - -using Newtonsoft.Json.Linq; - -namespace Deveel.Webhooks { - public static class HttpRequestMessageExtensions { - public static Task GetWebhookAsync(this HttpRequestMessage request, Action afterRead, CancellationToken cancellationToken) - where T : class - => request.GetWebhookAsync(new WebhookReceiveOptions(), afterRead, cancellationToken); - - public static Task GetWebhookAsync(this HttpRequestMessage request, WebhookReceiveOptions options, CancellationToken cancellationToken) - where T : class - => request.GetWebhookAsync(options, null, cancellationToken); - - public static Task GetWebhookAsync(this HttpRequestMessage request, WebhookReceiveOptions options) - where T : class - => request.GetWebhookAsync(options, default); - - public static Task GetWebhookAsync(this HttpRequestMessage request, CancellationToken cancellationToken) - where T : class - => request.GetWebhookAsync(new WebhookReceiveOptions(), cancellationToken); - - public static Task GetWebhookAsync(this HttpRequestMessage request) - where T : class - => request.GetWebhookAsync(default(CancellationToken)); - - public static async Task GetWebhookAsync(this HttpRequestMessage request, WebhookReceiveOptions options, Action afterRead, CancellationToken cancellationToken) - where T : class { - if (options != null && options.ValidateSignature) { - var content = await request.Content.ReadAsStringAsync(); - - if (!request.IsSignatureValid(content, options)) - throw new ArgumentException("The signature of the webhook is invalid"); - - return await WebhookJsonParser.ParseAsync(content, options.JsonSerializerSettings, afterRead, cancellationToken); - } - - return await request.Content.ReadAsObjectAsync(options?.JsonSerializerSettings, afterRead, cancellationToken); - } - - private static bool IsSignatureValid(this HttpRequestMessage request, string content, WebhookReceiveOptions options) { - string signature; - string algorithm = null; - - switch (options.SignatureLocation) { - case WebhookSignatureLocation.Header: - if (!request.Headers.TryGetValues(options.SignatureHeaderName, out var headerValue)) - return false; - - signature = headerValue.SingleOrDefault(); - - if (!string.IsNullOrEmpty(signature)) { - if (signature.StartsWith("sha256=")) { - signature = signature.Substring("sha256=".Length); - algorithm = "sha256"; - } - } - - break; - case WebhookSignatureLocation.QueryString: - if (string.IsNullOrWhiteSpace(request.RequestUri.Query)) - return false; - - var queryString = HttpUtility.ParseQueryString(request.RequestUri.Query); - signature = queryString[options.SignatureQueryStringKey]; - algorithm = queryString["sig_alg"]; - - break; - default: - // should never happen - throw new NotSupportedException(); - } - - if (string.IsNullOrWhiteSpace(signature)) - return false; - - return WebhookSignatureValidator.IsValid(algorithm, content, options.Secret, signature); - } - - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/IHttpWebhookReceiver.cs b/src/Deveel.Webhooks.Receiver/Webhooks/IHttpWebhookReceiver.cs deleted file mode 100644 index ab826a3..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/IHttpWebhookReceiver.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace Deveel.Webhooks { - public interface IHttpWebhookReceiver where T : class { - Task ReceiveAsync(HttpRequestMessage request, CancellationToken cancellationToken); - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/IWebhookReceiverBuilder.cs b/src/Deveel.Webhooks.Receiver/Webhooks/IWebhookReceiverBuilder.cs deleted file mode 100644 index c84a246..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/IWebhookReceiverBuilder.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; - -using Microsoft.Extensions.DependencyInjection; - -namespace Deveel.Webhooks { - public interface IWebhookReceiverBuilder { - IWebhookReceiverBuilder ConfigureServices(Action configure); - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/ServiceCollectionExtensions.cs b/src/Deveel.Webhooks.Receiver/Webhooks/ServiceCollectionExtensions.cs deleted file mode 100644 index 865df5b..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; - -using Microsoft.Extensions.DependencyInjection; - -namespace Deveel.Webhooks { - public static class ServiceCollectionExtensions { - public static IServiceCollection AddWebhookReceivers(this IServiceCollection services, Action configure = null) { - - - if (configure != null) { - var builder = new WebhookReceiverConfigurationBuilder(services); - configure(builder); - } - - return services; - } - - class WebhookReceiverConfigurationBuilder : IWebhookReceiverBuilder { - public WebhookReceiverConfigurationBuilder(IServiceCollection services) { - Services = services; - } - - public IServiceCollection Services { get; } - - public IWebhookReceiverBuilder ConfigureServices(Action configure) { - if (configure != null) - configure(Services); - - return this; - } - } - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookJsonParser.cs b/src/Deveel.Webhooks.Receiver/Webhooks/WebhookJsonParser.cs deleted file mode 100644 index 8760253..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookJsonParser.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace Deveel.Webhooks { - public static class WebhookJsonParser { - public static async Task ParseAsync(string content, JsonSerializerSettings serializerSettings, Action afterRead, CancellationToken cancellationToken) { - JToken token; - - using (var textReader = new StringReader(content)) { - using var jsonReader = new JsonTextReader(textReader); - token = await JToken.ReadFromAsync(jsonReader, cancellationToken); - } - - var result = token.ToObject(JsonSerializer.Create(serializerSettings)); - - afterRead?.Invoke((JObject)token, result); - - return result; - } - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookReceiveOptions.cs b/src/Deveel.Webhooks.Receiver/Webhooks/WebhookReceiveOptions.cs deleted file mode 100644 index c8e4246..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookReceiveOptions.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; - -using Newtonsoft.Json; - -namespace Deveel.Webhooks { - public class WebhookReceiveOptions { - public string Secret { get; set; } - - public bool ValidateSignature { get; set; } = false; - - public string SignatureHeaderName { get; set; } = "X-WEBHOOK-SIGNATURE"; - - public string SignatureQueryStringKey { get; set; } = "webhook-signature"; - - public WebhookSignatureLocation SignatureLocation { get; set; } = WebhookSignatureLocation.QueryString; - - public JsonSerializerSettings JsonSerializerSettings { get; set; } = new JsonSerializerSettings(); - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookReceiverConfigurationBuilderExtensions.cs b/src/Deveel.Webhooks.Receiver/Webhooks/WebhookReceiverConfigurationBuilderExtensions.cs deleted file mode 100644 index e3236b7..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookReceiverConfigurationBuilderExtensions.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; - -using Deveel.Webhooks; - -using Microsoft.Extensions.DependencyInjection; - -namespace Deveel.Webhooks { - public static class WebhookReceiverConfigurationBuilderExtensions { - public static IWebhookReceiverBuilder Configure(this IWebhookReceiverBuilder builder, Action configure) { - if (configure != null) - builder.ConfigureServices(services => services.Configure(configure)); - - return builder; - } - - public static IWebhookReceiverBuilder AddWebhookOptions(this IWebhookReceiverBuilder builder, WebhookReceiveOptions options) { - return builder.ConfigureServices(services => services.AddSingleton(options)); - } - - public static IWebhookReceiverBuilder AddHttpReceiver(this IWebhookReceiverBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Scoped) - where TReceiver : class, IHttpWebhookReceiver - where TWebhook : class { - builder.ConfigureServices(services => { - services.Add(new ServiceDescriptor(typeof(IHttpWebhookReceiver), typeof(TReceiver), lifetime)); - services.Add(new ServiceDescriptor(typeof(TReceiver), lifetime)); - }); - - return builder; - } - - public static IWebhookReceiverBuilder AddHttpReceiver(this IWebhookReceiverBuilder builder, TReceiver receiver) - where TReceiver : class, IHttpWebhookReceiver - where TWebhook : class { - builder.ConfigureServices(services => services - .AddSingleton>(receiver) - .AddSingleton(receiver)); - return builder; - } - - public static IWebhookReceiverBuilder AddHttpReceiver(this IWebhookReceiverBuilder builder) - where T : class - => builder.AddHttpReceiver, T>(); - - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookSignatureValidator.cs b/src/Deveel.Webhooks.Receiver/Webhooks/WebhookSignatureValidator.cs deleted file mode 100644 index eba4cc0..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookSignatureValidator.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Security.Cryptography; -using System.Text; - -namespace Deveel.Webhooks { - public static class WebhookSignatureValidator { - public static bool IsValid(string algorithm, string jsonPayload, string secret, string signature) { - if (string.IsNullOrWhiteSpace(signature)) - throw new ArgumentException($"'{nameof(signature)}' cannot be null or whitespace.", nameof(signature)); - if (string.IsNullOrWhiteSpace(secret)) - throw new ArgumentException($"'{nameof(secret)}' cannot be null or whitespace.", nameof(secret)); - if (string.IsNullOrWhiteSpace(algorithm)) - throw new ArgumentException($"'{nameof(algorithm)}' cannot be null or whitespace.", nameof(algorithm)); - - return algorithm switch { - "sha256" => IsValidSha256(jsonPayload, signature, secret), - _ => throw new NotSupportedException($"Te signing algorithm {algorithm} is not supported"), - }; - } - - public static bool IsValidSha256(string content, string signature, string secret) { - var secretBytes = Encoding.UTF8.GetBytes(secret); - - string compare; - - using (var hasher = new HMACSHA256(secretBytes)) { - var data = Encoding.UTF8.GetBytes(content); - var sha256 = hasher.ComputeHash(data); - - compare = BitConverter.ToString(sha256); - } - - return string.Equals(signature, compare); - } - } -} diff --git a/src/Deveel.Webhooks/Webhooks/Sha256WebhookSigner.cs b/src/Deveel.Webhooks/Webhooks/Sha256WebhookSigner.cs index 61ae30b..6dffaad 100644 --- a/src/Deveel.Webhooks/Webhooks/Sha256WebhookSigner.cs +++ b/src/Deveel.Webhooks/Webhooks/Sha256WebhookSigner.cs @@ -34,7 +34,7 @@ public string Sign(string serializedBody, string secret) { var data = Encoding.UTF8.GetBytes(serializedBody); var sha256 = hasher.ComputeHash(data); - signature = BitConverter.ToString(sha256); + signature = Convert.ToBase64String(sha256); } return signature; diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Deveel.Webhooks.Receiver.TestApi.csproj b/test/Deveel.Webhooks.Receiver.TestApi/Deveel.Webhooks.Receiver.TestApi.csproj new file mode 100644 index 0000000..746591a --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Deveel.Webhooks.Receiver.TestApi.csproj @@ -0,0 +1,14 @@ + + + + net6.0 + enable + enable + Deveel.Webhooks + + + + + + + diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestSignedWebhookHandler.cs b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestSignedWebhookHandler.cs new file mode 100644 index 0000000..a5de01a --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestSignedWebhookHandler.cs @@ -0,0 +1,19 @@ +using Deveel.Webhooks.Model; + +using Newtonsoft.Json; + +namespace Deveel.Webhooks.Handlers { + public class TestSignedWebhookHandler : IWebhookHandler { + private readonly ILogger _logger; + + public TestSignedWebhookHandler(ILogger logger) { + _logger = logger; + } + + public Task HandleAsync(TestSignedWebhook webhook, CancellationToken cancellationToken = default) { + _logger.LogInformation(JsonConvert.SerializeObject(webhook)); + + return Task.CompletedTask; + } + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs new file mode 100644 index 0000000..cf90af2 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs @@ -0,0 +1,23 @@ +using Deveel.Webhooks.Model; + +using Microsoft.Extensions.Options; + +using Newtonsoft.Json; + +namespace Deveel.Webhooks.Handlers { + public class TestWebhookHandler : IWebhookHandler { + private readonly ILogger _logger; + private readonly WebhookReceiverOptions options; + + public TestWebhookHandler(IOptions> options, ILogger logger) { + _logger = logger; + this.options = options.Value; + } + + public Task HandleAsync(TestWebhook webhook, CancellationToken cancellationToken = default) { + _logger.LogInformation(JsonConvert.SerializeObject(webhook)); + + return Task.CompletedTask; + } + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookReceiver.cs b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookReceiver.cs new file mode 100644 index 0000000..1316745 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookReceiver.cs @@ -0,0 +1,11 @@ +using Deveel.Webhooks.Model; + +namespace Deveel.Webhooks.Handlers { + public class TestWebhookReceiver : IWebhookReceiver { + public async Task> ReceiveAsync(HttpRequest request, CancellationToken cancellationToken) { + // TODO: test the signature as well ... + + return await request.ReadFromJsonAsync(); + } + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Model/TestSignedWebhook.cs b/test/Deveel.Webhooks.Receiver.TestApi/Model/TestSignedWebhook.cs new file mode 100644 index 0000000..9a2b629 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Model/TestSignedWebhook.cs @@ -0,0 +1,4 @@ +namespace Deveel.Webhooks.Model { + public class TestSignedWebhook : TestWebhook { + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Model/TestWebhook.cs b/test/Deveel.Webhooks.Receiver.TestApi/Model/TestWebhook.cs new file mode 100644 index 0000000..87ffe46 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Model/TestWebhook.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Deveel.Webhooks.Model { + public class TestWebhook { + public string Id { get; set; } + + public string Event { get; set; } + + [JsonConverter(typeof(UnixDateTimeConverter))] + public DateTimeOffset TimeStamp { get; set; } + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Program.cs b/test/Deveel.Webhooks.Receiver.TestApi/Program.cs new file mode 100644 index 0000000..4ae318b --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Program.cs @@ -0,0 +1,56 @@ +using Deveel.Webhooks.Handlers; +using Deveel.Webhooks.Model; + +using Newtonsoft.Json; + +namespace Deveel.Webhooks.Receiver.TestApi { + public class Program { + public static void Main(string[] args) { + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddLogging(); + + // Add services to the container. + builder.Services.AddAuthorization(); + builder.Services + .AddWebhooks() + // .UseReceiver() + .UseJsonParser(json => { + return JsonConvert.DeserializeObject(json); + }) + .AddHandler(); + + var secret = builder.Configuration["Webhook:Receiver:Signature:Secret"]; + + builder.Services.AddWebhooks() + .ConfigureOptions>(options => { + options.VerifySignature = true; + options.Signature.Secret = secret; + options.Signature.ParameterName = "X-Webhook-Signature-256"; + options.Signature.Location = WebhookSignatureLocation.Header; + }) + .UseJsonParser(json => JsonConvert.DeserializeObject(json)) + .UseSigner() + .AddHandler(); + + var app = builder.Build(); + + // Configure the HTTP request pipeline. + + app.UseHttpsRedirection(); + + app.UseAuthorization(); + + app.UseWebhookReceiver("/webhook"); + app.UseWebhookReceiver("/webhook/handled", async (context, webhook, ct) => { + var logger = context.RequestServices.GetRequiredService().CreateLogger("test"); + + logger.LogInformation(JsonConvert.ToString(webhook)); + }); + + app.UseWebhookReceiver("/webhook/signed"); + + app.Run(); + } + } +} \ No newline at end of file diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Properties/launchSettings.json b/test/Deveel.Webhooks.Receiver.TestApi/Properties/launchSettings.json new file mode 100644 index 0000000..b327b6a --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:1740", + "sslPort": 44341 + } + }, + "profiles": { + "Deveel.Webhooks.Receiver.TestApi": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "https://localhost:7184;http://localhost:5257", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "weatherforecast", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/appsettings.Development.json b/test/Deveel.Webhooks.Receiver.TestApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/appsettings.json b/test/Deveel.Webhooks.Receiver.TestApi/appsettings.json new file mode 100644 index 0000000..1d1a878 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Webhook": { + "Receiver": { + "Signature": { + "Secret": "qjs62wtg155s7dd7exdgdj" + } + } + } +} diff --git a/test/Deveel.Webhooks.Receiver.XUnit/Deveel.Webhooks.Receiver.XUnit.csproj b/test/Deveel.Webhooks.Receiver.XUnit/Deveel.Webhooks.Receiver.XUnit.csproj index 51e3726..95d5914 100644 --- a/test/Deveel.Webhooks.Receiver.XUnit/Deveel.Webhooks.Receiver.XUnit.csproj +++ b/test/Deveel.Webhooks.Receiver.XUnit/Deveel.Webhooks.Receiver.XUnit.csproj @@ -8,18 +8,14 @@ Deveel - - - - - - all runtime; build; native; contentfiles; analyzers; buildtransitive + + @@ -35,7 +31,7 @@ - + diff --git a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/HttpWebhookReceiverTests.cs b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/HttpWebhookReceiverTests.cs deleted file mode 100644 index e9130a8..0000000 --- a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/HttpWebhookReceiverTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; -using System.IO; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; - -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -using Xunit; - -namespace Deveel.Webhooks { - public class HttpWebhookReceiverTests { - private readonly IHttpWebhookReceiver httpReceiver; - private bool signed; - private WebhookSignatureLocation signatureLocation = WebhookSignatureLocation.QueryString; - - public HttpWebhookReceiverTests() { - var services = new ServiceCollection(); - - services.AddWebhookReceivers(webhook => webhook - .Configure(options => { - options.ValidateSignature = signed; - options.SignatureLocation = signatureLocation; - }) - .AddHttpReceiver()); - - var provider = services.BuildServiceProvider(); - httpReceiver = provider.GetRequiredService>(); - } - - [Fact] - public async Task ReceiveWebhookFromHttpRequestMessage() { - var webhook = new WebhookPayload { - WebhookName = "Test Webhook", - EventId = Guid.NewGuid().ToString("N"), - EventType = "event.occurred", - Data = JObject.FromObject(new TestData { - Key = "foo", - Value = "bar" - }) - }; - - var json = JObject.FromObject(webhook); - var requestMessage = new HttpRequestMessage(HttpMethod.Post, "https://callback.deveel.com"); - requestMessage.Content = new StringContent(json.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json"); - - var received = await requestMessage.GetWebhookAsync(); - - Assert.NotNull(received); - Assert.Equal(webhook.EventId, received.EventId); - Assert.Equal(webhook.EventType, received.EventType); - Assert.NotNull(webhook.Data); - Assert.Equal("foo", webhook.Data["Key"].Value()); - Assert.Equal("bar", webhook.Data["Value"].Value()); - } - - [Fact] - public async Task ReceiveWebhookFromHttpReceiver() { - var webhook = new WebhookPayload { - WebhookName = "Test Webhook", - EventId = Guid.NewGuid().ToString("N"), - EventType = "event.occurred", - Data = JObject.FromObject(new TestData { - Key = "foo", - Value = "bar" - }) - }; - - var json = JObject.FromObject(webhook); - var requestMessage = new HttpRequestMessage(HttpMethod.Post, "https://callback.deveel.com"); - requestMessage.Content = new StringContent(json.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json"); - - var received = await httpReceiver.ReceiveAsync(requestMessage, default); - - Assert.NotNull(received); - Assert.Equal(webhook.EventId, received.EventId); - Assert.Equal(webhook.EventType, received.EventType); - Assert.NotNull(webhook.Data); - Assert.Equal("foo", webhook.Data["Key"].Value()); - Assert.Equal("bar", webhook.Data["Value"].Value()); - } - - [Theory] - [InlineData(WebhookSignatureLocation.Header)] - [InlineData(WebhookSignatureLocation.QueryString)] - public async Task ReceiveSignedWebhookFromHttpRequest(WebhookSignatureLocation signatureLocation) { - var webhook = new WebhookPayload { - WebhookName = "Test Webhook", - EventId = Guid.NewGuid().ToString("N"), - EventType = "event.occurred", - Data = JObject.FromObject(new TestData { - Key = "foo", - Value = "bar" - }) - }; - - var secret = Guid.NewGuid().ToString("N"); - - var options = new WebhookReceiveOptions { - Secret = secret, - ValidateSignature = true, - SignatureLocation = signatureLocation - }; - - var json = JObject.FromObject(webhook); - var jsonString = json.ToString(Newtonsoft.Json.Formatting.None); - var signature = new Sha256WebhookSigner().Sign(jsonString, secret); - - var requestUri = new UriBuilder("https://callback.deveel.com"); - - if (signatureLocation == WebhookSignatureLocation.QueryString) - requestUri.Query = $"?sig_alg=sha256&webhook-signature={signature}"; - - var requestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri.Uri); - - if (signatureLocation == WebhookSignatureLocation.Header) { - requestMessage.Headers.TryAddWithoutValidation(options.SignatureHeaderName, $"sha256={signature}"); - } - - requestMessage.Content = new StringContent(jsonString, Encoding.UTF8, "application/json"); - - var received = await requestMessage.GetWebhookAsync(options); - - Assert.NotNull(received); - Assert.Equal(webhook.EventId, received.EventId); - Assert.Equal(webhook.EventType, received.EventType); - Assert.NotNull(webhook.Data); - Assert.Equal("foo", webhook.Data["Key"].Value()); - Assert.Equal("bar", webhook.Data["Value"].Value()); - } - - class TestData { - public string Key { get; set; } - - public string Value { get; set; } - } - } -} diff --git a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookPayload.cs b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookPayload.cs deleted file mode 100644 index 7e812bc..0000000 --- a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookPayload.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace Deveel.Webhooks { - class WebhookPayload { - [JsonProperty("webhook")] - public string WebhookName { get; set; } - - [JsonProperty("event_id")] - public string EventId { get; set; } - - [JsonProperty("event_name")] - public string EventType { get; set; } - - - [JsonExtensionData] - public JObject Data { get; set; } - } -} diff --git a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs new file mode 100644 index 0000000..7b98550 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +using Deveel.Webhooks.Model; +using Deveel.Webhooks.Receiver.TestApi; + +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +using Xunit; +using Xunit.Abstractions; + +namespace Deveel.Webhooks { + public class WebhookReceiveRequestTests : IDisposable { + private readonly WebApplicationFactory appFactory; + + public WebhookReceiveRequestTests(ITestOutputHelper outputHelper) { + appFactory = new WebApplicationFactory() + .WithWebHostBuilder(builder => builder.ConfigureLogging(logging => logging.AddXUnit(outputHelper).SetMinimumLevel(LogLevel.Trace))); + } + + public void Dispose() => appFactory?.Dispose(); + + private HttpClient CreateClient() => appFactory.CreateClient(); + + [Fact] + public async Task ReceiveTestWebhook() { + var client = CreateClient(); + + var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/webhook") { + Content = new StringContent(JsonConvert.SerializeObject(new TestWebhook { + Id = Guid.NewGuid().ToString("N"), + Event = "test", + TimeStamp = DateTimeOffset.Now, + }), Encoding.UTF8, "application/json") + }); + + Assert.True(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private string GetSha256Signature(string json) { + var config = appFactory.Services.GetRequiredService(); + + var secret = config["Webhook:Receiver:Signature:Secret"]; + + var sha256Signer = new Sha256WebhookSigner(); + return sha256Signer.SignWebhook(json, secret); + } + + [Fact] + public async Task ReceiveSignedTestWebhook() { + var client = CreateClient(); + + var json = JsonConvert.SerializeObject(new TestSignedWebhook { + Id = Guid.NewGuid().ToString("N"), + Event = "test", + TimeStamp = DateTimeOffset.Now, + }); + + var sha256Sig = GetSha256Signature(json); + + var request = new HttpRequestMessage(HttpMethod.Post, "/webhook/signed") { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + request.Headers.TryAddWithoutValidation("X-Webhook-Signature-256", sha256Sig); + + var response = await client.SendAsync(request); + + Assert.True(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task ReceiveSignedTestWebhook_InvalidSignature() { + var client = CreateClient(); + + var json = JsonConvert.SerializeObject(new TestSignedWebhook { + Id = Guid.NewGuid().ToString("N"), + Event = "test", + TimeStamp = DateTimeOffset.Now, + }); + + var sha256Sig = GetSha256Signature(json + "..."); + + var request = new HttpRequestMessage(HttpMethod.Post, "/webhook/signed") { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + request.Headers.TryAddWithoutValidation("X-Webhook-Signature-256", sha256Sig); + + var response = await client.SendAsync(request); + + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task ReceiveSignedTestWebhook_NoSignature() { + var client = CreateClient(); + + var json = JsonConvert.SerializeObject(new TestSignedWebhook { + Id = Guid.NewGuid().ToString("N"), + Event = "test", + TimeStamp = DateTimeOffset.Now, + }); + + var request = new HttpRequestMessage(HttpMethod.Post, "/webhook/signed") { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(request); + + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + } +} diff --git a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiverTests.cs b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiverTests.cs deleted file mode 100644 index 4fb148a..0000000 --- a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiverTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net.Http; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Internal; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.VisualStudio.TestPlatform.Common.ExtensionFramework; -using Microsoft.VisualStudio.TestPlatform.Common.Interfaces; - -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -using Xunit; - -namespace Deveel.Webhooks { - public class WebhookReceiverTests { - private readonly IWebhookReceiver receiver; - private bool signed; - private WebhookSignatureLocation signatureLocation = WebhookSignatureLocation.QueryString; - - public WebhookReceiverTests() { - var services = new ServiceCollection(); - services.AddWebhookReceivers(webhook => webhook - .Configure(options => { - options.ValidateSignature = signed; - options.SignatureLocation = signatureLocation; - }) - .AddReceiver()); - - var provider = services.BuildServiceProvider(); - receiver = provider.GetRequiredService>(); - } - - [Fact] - public async Task ReceiveWebhookFromRequest() { - var webhook = new WebhookPayload { - WebhookName = "Test Webhook", - EventId = Guid.NewGuid().ToString("N"), - EventType = "event.occurred", - Data = JObject.FromObject(new TestData { - Key = "foo", - Value = "bar" - }) - }; - - var json = JObject.FromObject(webhook); - var stream = new MemoryStream(); - - var writer = new StreamWriter(stream); - var jsonWriter = new JsonTextWriter(writer); - await json.WriteToAsync(jsonWriter); - await jsonWriter.FlushAsync(); - - stream.Position = 0; - - var context = new DefaultHttpContext(); - context.Request.ContentType = "application/json"; - context.Request.Method = "POST"; - context.Request.Body = stream; - - var received = await context.Request.GetWebhookAsync(); - - Assert.NotNull(received); - Assert.Equal(webhook.EventId, received.EventId); - Assert.Equal(webhook.EventType, received.EventType); - Assert.NotNull(webhook.Data); - Assert.Equal("foo", webhook.Data["Key"].Value()); - Assert.Equal("bar", webhook.Data["Value"].Value()); - } - - [Fact] - public async Task ReceiveWebhookFromReceiver() { - var webhook = new WebhookPayload { - WebhookName = "Test Webhook", - EventId = Guid.NewGuid().ToString("N"), - EventType = "event.occurred", - Data = JObject.FromObject(new TestData { - Key = "foo", - Value = "bar" - }) - }; - - var json = JObject.FromObject(webhook); - var stream = new MemoryStream(); - - var writer = new StreamWriter(stream); - var jsonWriter = new JsonTextWriter(writer); - await json.WriteToAsync(jsonWriter); - await jsonWriter.FlushAsync(); - - stream.Position = 0; - - var context = new DefaultHttpContext(); - context.Request.ContentType = "application/json"; - context.Request.Method = "POST"; - context.Request.Body = stream; - - var received = await receiver.ReceiveAsync(context.Request, default); - - Assert.NotNull(received); - Assert.Equal(webhook.EventId, received.EventId); - Assert.Equal(webhook.EventType, received.EventType); - Assert.NotNull(webhook.Data); - Assert.Equal("foo", webhook.Data["Key"].Value()); - Assert.Equal("bar", webhook.Data["Value"].Value()); - } - - - [Theory] - [InlineData(WebhookSignatureLocation.Header)] - [InlineData(WebhookSignatureLocation.QueryString)] - public async Task ReceiveSignedWebhook(WebhookSignatureLocation signatureLocation) { - var webhook = new WebhookPayload { - WebhookName = "Test Webhook", - EventId = Guid.NewGuid().ToString("N"), - EventType = "event.occurred", - Data = JObject.FromObject(new TestData { - Key = "foo", - Value = "bar" - }) - }; - - var secret = Guid.NewGuid().ToString("N"); - - var options = new WebhookReceiveOptions { - Secret = secret, - ValidateSignature = true, - SignatureLocation = signatureLocation - }; - - var json = JObject.FromObject(webhook); - - var signature = new Sha256WebhookSigner().Sign(json.ToString(Formatting.None), secret); - - var stream = new MemoryStream(); - - var writer = new StreamWriter(stream); - var jsonWriter = new JsonTextWriter(writer); - await json.WriteToAsync(jsonWriter); - await jsonWriter.FlushAsync(); - - stream.Position = 0; - - var context = new DefaultHttpContext(); - context.Request.ContentType = "application/json"; - context.Request.Method = "POST"; - if (signatureLocation == WebhookSignatureLocation.QueryString) { - context.Request.QueryString = new QueryString($"?sig_alg=sha256&webhook-signature={signature}"); - } else { - context.Request.Headers.TryAdd(options.SignatureHeaderName, $"sha256={signature}"); - } - - context.Request.Body = stream; - - var received = await context.Request.GetWebhookAsync(options); - - Assert.NotNull(received); - Assert.Equal(webhook.EventId, received.EventId); - Assert.Equal(webhook.EventType, received.EventType); - Assert.NotNull(webhook.Data); - Assert.Equal("foo", webhook.Data["Key"].Value()); - Assert.Equal("bar", webhook.Data["Value"].Value()); - } - - class TestData { - public string Key { get; set; } - - public string Value { get; set; } - } - } -} From b9f33e52c2ccba862bdaf335c297d0c1223c636a Mon Sep 17 00:00:00 2001 From: Antonello Provenzano Date: Wed, 19 Apr 2023 00:09:54 +0200 Subject: [PATCH 2/8] Adjustments and fixes to the webhook receiver middlewares and parsers --- .../Webhooks/ApplicationBuilderExtensions.cs | 9 ++++ .../Webhooks/NewtonsoftWebhookJsonParser.cs | 28 +++++++++++ .../Webhooks/SystemTextWebhookJsonParser.cs | 23 +++++++++ .../WebhookDelegatedReceiverMiddleware.cs | 47 ++++++++++++++++--- .../Webhooks/WebhookException.cs | 17 +++++++ .../Webhooks/WebhookParseException.cs | 14 ++++++ .../Webhooks/WebhookReceiver.cs | 47 +++++++++---------- .../Webhooks/WebhookReceiverBuilder.cs | 45 ++++++++++++++++++ .../Program.cs | 11 ++--- .../Webhooks/WebhookReceiveRequestTests.cs | 17 +++++++ 10 files changed, 221 insertions(+), 37 deletions(-) create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/NewtonsoftWebhookJsonParser.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/SystemTextWebhookJsonParser.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookException.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookParseException.cs diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs index eaf7afa..e355ead 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs @@ -33,5 +33,14 @@ public static IApplicationBuilder UseWebhookReceiver(this IApplication builder => builder.UseMiddleware>(receiver) ); } + + public static IApplicationBuilder UseWebhookReceiver(this IApplicationBuilder app, string path, Action receiver) + where TWebhook : class { + return app.MapWhen( + context => context.Request.Method == "POST" && context.Request.Path.Equals(path), + builder => builder.UseMiddleware>(receiver) + ); + } + } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/NewtonsoftWebhookJsonParser.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/NewtonsoftWebhookJsonParser.cs new file mode 100644 index 0000000..fcfeed3 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/NewtonsoftWebhookJsonParser.cs @@ -0,0 +1,28 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Newtonsoft.Json; + +namespace Deveel.Webhooks { + public sealed class NewtonsoftWebhookJsonParser : IWebhookJsonParser { + public NewtonsoftWebhookJsonParser(JsonSerializerSettings settings = null) { + JsonSerializerSettings = settings ?? new JsonSerializerSettings(); + } + + public JsonSerializerSettings JsonSerializerSettings { get; } + + public async Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default) { + try { + using var textReader = new StreamReader(utf8Stream, Encoding.UTF8); + var json = await textReader.ReadToEndAsync(); + + return JsonConvert.DeserializeObject(json, JsonSerializerSettings); + } catch (Exception ex) { + throw new WebhookParseException("Could not parse the stream to a webhook", ex); + } + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/SystemTextWebhookJsonParser.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/SystemTextWebhookJsonParser.cs new file mode 100644 index 0000000..c240908 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/SystemTextWebhookJsonParser.cs @@ -0,0 +1,23 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Deveel.Webhooks { + public sealed class SystemTextWebhookJsonParser : IWebhookJsonParser where TWebhook : class { + public SystemTextWebhookJsonParser(JsonSerializerOptions options = null) { + JsonSerializerOptions = options ?? new JsonSerializerOptions(); + } + + public JsonSerializerOptions JsonSerializerOptions { get; } + + public async Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default) { + try { + return await JsonSerializer.DeserializeAsync(utf8Stream, JsonSerializerOptions, cancellationToken); + } catch (Exception ex) { + throw new WebhookParseException("Could not parse the stream to a webhook", ex); + } + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs index 5ec0c25..691b573 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs @@ -3,19 +3,54 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; namespace Deveel.Webhooks { - class WebhookDelegatedReceiverMiddleware { + class WebhookDelegatedReceiverMiddleware where TWebhook : class { private readonly RequestDelegate next; - private readonly Func receiver; + private readonly Func asyncHandler; + private readonly Action syncHandler; - public WebhookDelegatedReceiverMiddleware(RequestDelegate next, Func receiver) { + public WebhookDelegatedReceiverMiddleware(RequestDelegate next, + Func handler) { this.next = next; - this.receiver = receiver; + this.asyncHandler = handler; } - public Task InvokeAsync(HttpContext context) { - return Task.CompletedTask; + public WebhookDelegatedReceiverMiddleware(RequestDelegate next, + Action handler) { + this.next = next; + this.syncHandler = handler; + } + + + public async Task InvokeAsync(HttpContext context) { + try { + var receiver = context.RequestServices.GetService>(); + if (receiver == null) { + await next(context); + } else { + var result = await receiver.ReceiveAsync(context.Request, context.RequestAborted); + + if (result.SignatureValid != null && !result.SignatureValid.Value) { + // TODO: get this from the configuration + context.Response.StatusCode = 400; + } else if (result.Webhook == null) { + context.Response.StatusCode = 400; + } else if (asyncHandler != null) { + await asyncHandler(context, result.Webhook, context.RequestAborted); + } else if (syncHandler != null) { + syncHandler(context, result.Webhook); + } else { + await next(context); + } + } + } catch (Exception ex) { + // TODO: log this error ... + + context.Response.StatusCode = 500; + // TODO: should we emit anything here? + } } } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookException.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookException.cs new file mode 100644 index 0000000..755eeed --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookException.cs @@ -0,0 +1,17 @@ +using System; + +namespace Deveel.Webhooks { + public class WebhookException : Exception { + public WebhookException(string message, Exception innerException) + : base(message, innerException) { + } + + public WebhookException(string message) + : base(message) { + } + + public WebhookException() { + + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookParseException.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookParseException.cs new file mode 100644 index 0000000..e333f41 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookParseException.cs @@ -0,0 +1,14 @@ +using System; + +namespace Deveel.Webhooks { + public sealed class WebhookParseException : WebhookException { + public WebhookParseException() { + } + + public WebhookParseException(string message) : base(message) { + } + + public WebhookParseException(string message, Exception innerException) : base(message, innerException) { + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs index 5e64cb0..ff77e46 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -101,16 +100,6 @@ protected virtual bool IsSignatureValid(string signature, string algorithm, stri return String.Equals(computedSignature, signature, StringComparison.OrdinalIgnoreCase); } - //private bool ValidateSha256Signature(string signature, string jsonBody, string secret) { - // var key = Encoding.ASCII.GetBytes(secret); - - // using var sha256 = new HMACSHA256(key); - // var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(jsonBody)); - // var computedSignature = BitConverter.ToString(hash); - - // return String.Equals(signature, computedSignature, StringComparison.Ordinal); - //} - protected async Task TryValidateWebhook(HttpRequest request) { using var reader = new StreamReader(request.Body, Encoding.UTF8); var jsonBody = await reader.ReadToEndAsync(); @@ -125,21 +114,31 @@ protected async Task TryValidateWebhook(HttpRequest request) { } public virtual async Task> ReceiveAsync(HttpRequest request, CancellationToken cancellationToken) { - if (ValidateSignature()) { - var result = await TryValidateWebhook(request); - - if (result.SignatureValidated && !(result.IsValid ?? false)) { - return new WebhookReceiveResult(null, false); - } else if ((result.SignatureValidated && (result.IsValid ?? false)) || - !result.SignatureValidated) { - var signatureValid = result.SignatureValidated && (result.IsValid ?? false); - var webhook = await ParseJsonAsync(result.JsonBody, cancellationToken); - return new WebhookReceiveResult(webhook, signatureValid); + if (String.IsNullOrWhiteSpace(request.ContentType) || + !request.ContentType.StartsWith("application/json")) + return new WebhookReceiveResult(null, null); + + try { + if (ValidateSignature()) { + var result = await TryValidateWebhook(request); + + if (result.SignatureValidated && !(result.IsValid ?? false)) { + return new WebhookReceiveResult(null, false); + } else if ((result.SignatureValidated && (result.IsValid ?? false)) || + !result.SignatureValidated) { + var signatureValid = result.SignatureValidated && (result.IsValid ?? false); + var webhook = await ParseJsonAsync(result.JsonBody, cancellationToken); + return new WebhookReceiveResult(webhook, signatureValid); + } else { + throw new NotSupportedException(); + } } else { - throw new NotSupportedException(); + return await ParseJsonAsync(request.Body, cancellationToken); } - } else { - return await ParseJsonAsync(request.Body, cancellationToken); + } catch (WebhookException) { + throw; + } catch(Exception ex) { + throw new WebhookException("Could not receive the webhook", ex); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs index 0eb9576..3dbd4b6 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs @@ -4,11 +4,15 @@ using System.Linq; using System.Runtime.CompilerServices; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +using Newtonsoft.Json; namespace Deveel.Webhooks { public sealed class WebhookReceiverBuilder { @@ -25,6 +29,8 @@ public WebhookReceiverBuilder(Type webhookType, IServiceCollection services) { RegisterReceiverMiddleware(); RegisterVerifierMiddleware(); RegisterDefaultReceiver(); + + UseJsonParser(); } public Type WebhookType { get; } @@ -115,6 +121,45 @@ public WebhookReceiverBuilder UseJsonParser(ServiceLifetime lifetime = return this; } + public WebhookReceiverBuilder UseJsonParser(TParser parser) + where TParser : class { + var parserType = typeof(IWebhookJsonParser<>).MakeGenericType(WebhookType); + + if (!parserType.IsAssignableFrom(typeof(TParser))) + throw new ArgumentException($"The type '{typeof(TParser)}' is not assignable to '{parserType}'"); + + Services.RemoveAll(parserType); + Services.AddSingleton(parserType, parser); + + Services.AddSingleton(parser); + + return this; + } + + public WebhookReceiverBuilder UseJsonParser(JsonSerializerOptions options = null) { + var parserType = typeof(IWebhookJsonParser<>).MakeGenericType(WebhookType); + var jsonParserType = typeof(SystemTextWebhookJsonParser<>).MakeGenericType(WebhookType); + var parser = Activator.CreateInstance(jsonParserType, new[] {options}); + + Services.RemoveAll(parserType); + Services.AddSingleton(parserType, parser); + Services.AddSingleton(jsonParserType, parser); + + return this; + } + + public WebhookReceiverBuilder UseNewtonsoftJsonParser(JsonSerializerSettings settings = null) { + var parserType = typeof(IWebhookJsonParser<>).MakeGenericType(WebhookType); + var jsonParserType = typeof(NewtonsoftWebhookJsonParser<>).MakeGenericType(WebhookType); + var parser = Activator.CreateInstance(jsonParserType, new[] { settings }); + + Services.RemoveAll(parserType); + Services.AddSingleton(parserType, parser); + Services.AddSingleton(jsonParserType, parser); + + return this; + } + public WebhookReceiverBuilder UseJsonParser(Func> parser) where TWebhook : class { if (typeof(TWebhook) != WebhookType) diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Program.cs b/test/Deveel.Webhooks.Receiver.TestApi/Program.cs index 4ae318b..0030775 100644 --- a/test/Deveel.Webhooks.Receiver.TestApi/Program.cs +++ b/test/Deveel.Webhooks.Receiver.TestApi/Program.cs @@ -14,10 +14,7 @@ public static void Main(string[] args) { builder.Services.AddAuthorization(); builder.Services .AddWebhooks() - // .UseReceiver() - .UseJsonParser(json => { - return JsonConvert.DeserializeObject(json); - }) + .UseNewtonsoftJsonParser() .AddHandler(); var secret = builder.Configuration["Webhook:Receiver:Signature:Secret"]; @@ -29,7 +26,7 @@ public static void Main(string[] args) { options.Signature.ParameterName = "X-Webhook-Signature-256"; options.Signature.Location = WebhookSignatureLocation.Header; }) - .UseJsonParser(json => JsonConvert.DeserializeObject(json)) + .UseNewtonsoftJsonParser() .UseSigner() .AddHandler(); @@ -42,10 +39,10 @@ public static void Main(string[] args) { app.UseAuthorization(); app.UseWebhookReceiver("/webhook"); - app.UseWebhookReceiver("/webhook/handled", async (context, webhook, ct) => { + app.UseWebhookReceiver("/webhook/handled", (context, webhook) => { var logger = context.RequestServices.GetRequiredService().CreateLogger("test"); - logger.LogInformation(JsonConvert.ToString(webhook)); + logger.LogInformation(JsonConvert.SerializeObject(webhook)); }); app.UseWebhookReceiver("/webhook/signed"); diff --git a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs index 7b98550..14a72ca 100644 --- a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs +++ b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs @@ -50,6 +50,23 @@ public async Task ReceiveTestWebhook() { Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + [Fact] + public async Task ReceiveHandledTestWebhook() { + var client = CreateClient(); + + var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/webhook/handled") { + Content = new StringContent(JsonConvert.SerializeObject(new TestWebhook { + Id = Guid.NewGuid().ToString("N"), + Event = "test", + TimeStamp = DateTimeOffset.Now, + }), Encoding.UTF8, "application/json") + }); + + Assert.True(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private string GetSha256Signature(string json) { var config = appFactory.Services.GetRequiredService(); From c476cc124499cee2fd248cdd53208132d50b4bf8 Mon Sep 17 00:00:00 2001 From: Antonello Provenzano Date: Wed, 19 Apr 2023 13:13:11 +0200 Subject: [PATCH 3/8] Changing the design of the webhook receiver builder and documentation --- Deveel.Webhooks.sln | 9 +- README.md | 2 +- apl-2.licenseheader | 14 + docs/README.md | 1 + docs/basic_usage_receive.md | 128 ++ docs/getting_started.md | 22 +- .../Deveel.Webhooks.DynamicLinq.csproj | 8 +- .../Deveel.Webhooks.Model.csproj | 4 + ...ebhooks.Receiver.AspNetCore.NewtonsoftJson | 50 + ....Receiver.AspNetCore.NewtonsoftJson.csproj | 45 + .../Webhooks/NewtonsoftWebhookJsonParser.cs | 55 + .../WebhookReceiverBuilderExtensions.cs | 46 + ...Deveel.Webhooks.Receiver.AspNetCore.csproj | 70 +- .../Deveel.Webhooks.Receiver.AspNetCore.xml | 1061 +++++++++++++++++ .../ServiceCollectionExtensions.cs | 93 +- .../Webhooks/ApplicationBuilderExtensions.cs | 149 ++- .../Webhooks/IWebhookHandler.cs | 42 +- .../Webhooks/IWebhookJsonParser.cs | 38 +- .../Webhooks/IWebhookReceiver.cs | 36 +- .../Webhooks/IWebhookRequestVerifier.cs | 45 +- .../Webhooks/IWebhookSigner.cs | 36 +- .../Webhooks/IWebhookSignerProvider.cs | 32 +- .../Webhooks/IWebhookSigner_1.cs | 36 +- .../Webhooks/NewtonsoftWebhookJsonParser.cs | 28 - .../Webhooks/OptionsSnapshotExtensions.cs | 35 + .../Webhooks/Sha256WebhookSigner.cs | 42 +- .../Webhooks/SystemTextWebhookJsonParser.cs | 43 +- .../WebhookDelegatedReceiverMiddleware.cs | 26 +- .../Webhooks/WebhookException.cs | 17 - .../Webhooks/WebhookJsonParserExtensions.cs | 45 + .../Webhooks/WebhookParseException.cs | 28 +- .../Webhooks/WebhookReceiveResult.cs | 58 +- .../Webhooks/WebhookReceiver.cs | 263 +++- .../Webhooks/WebhookReceiverBuilder.cs | 387 +++--- .../Webhooks/WebhookReceiverException.cs | 37 + ...leware.cs => WebhookReceiverMiddleware.cs} | 22 +- .../Webhooks/WebhookReceiverOptions.cs | 44 +- .../WebhookRequestVerfierMiddleware.cs | 16 +- .../Webhooks/WebhookSignatureLocation.cs | 13 +- .../Webhooks/WebhookSignatureOptions.cs | 42 +- .../Webhooks/WebhookVerificationResult.cs | 51 + .../Deveel.Webhooks.MongoDb.csproj | 6 +- src/Deveel.Webhooks/Deveel.Webhooks.csproj | 6 +- .../Deveel.Webhooks.Receiver.TestApi.csproj | 1 + .../Handlers/TestWebhookHandler.cs | 6 +- .../Program.cs | 2 +- 46 files changed, 2876 insertions(+), 364 deletions(-) create mode 100644 apl-2.licenseheader create mode 100644 docs/basic_usage_receive.md create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson.csproj create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Webhooks/NewtonsoftWebhookJsonParser.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Webhooks/WebhookReceiverBuilderExtensions.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml delete mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/NewtonsoftWebhookJsonParser.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/OptionsSnapshotExtensions.cs delete mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookException.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookJsonParserExtensions.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverException.cs rename src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/{WebhookRceiverMiddleware.cs => WebhookReceiverMiddleware.cs} (56%) create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationResult.cs diff --git a/Deveel.Webhooks.sln b/Deveel.Webhooks.sln index eca4034..4b92118 100644 --- a/Deveel.Webhooks.sln +++ b/Deveel.Webhooks.sln @@ -23,7 +23,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{07F23FF6-2 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks.Receiver.XUnit", "test\Deveel.Webhooks.Receiver.XUnit\Deveel.Webhooks.Receiver.XUnit.csproj", "{4BC8323C-74F7-407A-8A5A-EA595B5C5585}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deveel.Webhooks.Receiver.TestApi", "test\Deveel.Webhooks.Receiver.TestApi\Deveel.Webhooks.Receiver.TestApi.csproj", "{CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks.Receiver.TestApi", "test\Deveel.Webhooks.Receiver.TestApi\Deveel.Webhooks.Receiver.TestApi.csproj", "{CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson", "src\Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson\Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson.csproj", "{F23E99C7-8228-4AEE-894B-CAA303686239}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -67,6 +69,10 @@ Global {CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}.Debug|Any CPU.Build.0 = Debug|Any CPU {CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}.Release|Any CPU.ActiveCfg = Release|Any CPU {CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}.Release|Any CPU.Build.0 = Release|Any CPU + {F23E99C7-8228-4AEE-894B-CAA303686239}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F23E99C7-8228-4AEE-894B-CAA303686239}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F23E99C7-8228-4AEE-894B-CAA303686239}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F23E99C7-8228-4AEE-894B-CAA303686239}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -81,6 +87,7 @@ Global {69EA8584-6336-4A62-BE73-DE04DC6EE8E1} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {4BC8323C-74F7-407A-8A5A-EA595B5C5585} = {07F23FF6-2FE1-4072-BF37-9238E3750AA1} {CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F} = {07F23FF6-2FE1-4072-BF37-9238E3750AA1} + {F23E99C7-8228-4AEE-894B-CAA303686239} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E682A9F5-43D7-4D4C-82EA-953545B8F4DE} diff --git a/README.md b/README.md index 7d86146..ee8572e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Although this integration model is widely adopted by major service providers (li Anyway, a typical implementation consists of the following elements: * Webhooks are transported through _HTTP POST_ callbacks -* The webhook payload is represented as a JSON object (or alternatively as XML or Form) +* The webhook payload is formatted as a JSON object (or alternatively, in lesser common scenarios, as XML or Form) * The webhook payload includes properties that describe the type of event and the time-stamp of the occurrence * An optional signature in the header of the request or a query-string parameter ensures the authenticity of the caller diff --git a/apl-2.licenseheader b/apl-2.licenseheader new file mode 100644 index 0000000..4ed22b3 --- /dev/null +++ b/apl-2.licenseheader @@ -0,0 +1,14 @@ +extensions: .cs +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. diff --git a/docs/README.md b/docs/README.md index adc055d..8e736fc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,7 @@ | **[Basic Usage - Sending Webhooks](basic_usage_send.md)** | Manually sending webhooks (no subscriptions) | | **[Basic Usage - Subscription Management](basic_usage_management.md)** | Manage subscriptions to events (no sending) | | **[Basic Usage - Notify Webhooks](basic_usage_notify.md)** | Notify webhooks subscribers (management, transformations and sending) | +| **[Basic Usage - Receiving Webhooks](basic_usage_receive.md)** | Receive webhooks from external sources | ## Extending diff --git a/docs/basic_usage_receive.md b/docs/basic_usage_receive.md new file mode 100644 index 0000000..f59ce03 --- /dev/null +++ b/docs/basic_usage_receive.md @@ -0,0 +1,128 @@ +# Receive Webhooks from External Sources + +## Installations + +When receiving webhooks from external sources, you can use the `Deveel.Webhooks.Receiver.AspNetCore` library, that allows the registration of a webhook receiver in an ASP.NET Core application. + +To enable this function you must first install the NuGet package: + +```bash +dotnet add package Deveel.Webhooks.Receiver.AspNetCore +``` + + +## Registering the Webhook Receiver + +Then, in the `Startup` class of your application, you can register the webhook receiver as follows: + +```csharp +public class Startup { + public void ConfigureServices(IServiceCollection services) { + services.AddWebhooks(); + } +} +``` + +Or alternatively, if you are using the mininal API pattern, you can use the following code: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddWebhooks(); +``` + +This simple call registers the webhook receiver in the application, and allows to receive webhooks of type `MyWebhook`: receivers are segregated by the type of webhook they can handle, and you can register multiple receivers for different types of webhooks. + +By default the registration of the webhook receiver adds a set of default services, that are required to handle the webhooks, such as the `IWebhookReceiver`, `IWebhookHandler`, `IWebhookJsonParser` and a default set of options: you can control further the services and configurations by using the builder instance returned by the `AddWebhooks` method. + +## Receiving Webhooks - Using Controllers + +Following the registration of the webhook receiver, you can receive webhooks by using the `IWebhookReceiver` service, that is registered in the application, if you want to handle the receive process directly. + +This approach is typical in MVC APIs that implement the request processing in the controller, and can be used as follows: + +```csharp +namespace Demo { + [ApiController] + [Route("webhook")] + public class WebhookController : ControllerBase { + private readonly IWebhookReceiver webhookReceiver; + private readonly IWebhookHandler webhookHandler; + + public WebhookController(IWebhookReceiver webhookReceiver, IWebhookHandler webhookHandler) { + this.webhookReceiver = webhookReceiver; + this.webhookHandler = webhookHandler; + } + + [HttpPost] + public async Task ReceiveWebhook() { + var result = await webhookReceiver.ReceiveAsync(Request, HttpContext.RequestAborted); + if (!result.IsValid) + return BadRequest(result.Error); + + var webhook = result.Webhook; + await webhookHandler.HandleAsync(webhook, HttpContext.RequestAborted); + + return Ok(); + } + } +} +``` + +Mind that in the above scenario you must also inject the `IWebhookHandler` service, that is used to handle the received webhook. + +## Receiving Webhooks - Using Middleware + +Alternatively the `Deveel.Webhooks.Receiver.AspNetCore` library provides a middleware that can be used to receive webhooks, and handle them automatically. + +To use the middleware, you must first register it in the `Startup` class of your application: + +```csharp +public class Startup { + public void ConfigureServices(IServiceCollection services) { + services.AddWebhooks(); + } + +public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { + app.UseWebhooks("/webhook"); + } +} +``` + +If you are using the minimal API pattern, you can use the following code: + +```csharp + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddWebhooks(); + +var app = builder.Build(); + +app.UseWebhooks("/webhook"); + +app.Run(); +``` + +The above code registers the middleware in the application, and allows to receive webhooks of type `MyWebhook` at the `/webhook` endpoint, using the configurations defined when registering the receiver, + +The middlware will automatically scan for all the registered webhook receivers, and will handle the received webhooks by using the `IWebhookHandler` service. + +The middleware design allows to handle the webhooks without any prior registered handler, by specifying an handling delegate in the `UseWebhooks` method: + +```csharp +[...] + +app.UseWebhooks("/webhook", (context, webhook, cancellationToken) => { + // Handle the webhook here +}); +``` + +Or a alternatively a synchronous handling delegate: + +```csharp +[...] + +app.UseWebhooks("/webhook", (context, webhook) => { + // Handle the webhook here +}); +``` \ No newline at end of file diff --git a/docs/getting_started.md b/docs/getting_started.md index df4c675..deeb5d5 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -16,7 +16,7 @@ # Getting Started -The overall design of this little framework is open and extensible (implementing the traditional [Open-Closed Principle](https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle)), that means base contracts can be extended, composed or replaced. +The overall design of this framework is open and extensible (implementing the traditional [Open-Closed Principle](https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle)), that means base contracts can be extended, composed or replaced. It is possible to use its components as they are provided, or use the base contracts to extend single functions, while still using the rest of the provisioning. @@ -38,13 +38,13 @@ Or by editing your `.csproj` file and adding a `` entry. - netcoreapp3.1 + ne5.0 ... - + ... @@ -56,17 +56,17 @@ This provides all the functions that are needed to send webhooks to a given dest The libraries currently provided by the framework are the following: -| Library | Description | NuGet | -| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| **Deveel.Webhooks.Model** | The foundation library that defines the webhooks information model | [Package](https://www.nuget.org/packages/Deveel.Webhooks.Model/) | -| **Deveel.Webhooks** | Provides the foundation contracts of the webhook service and basic implementations for the sending functions | [Package](https://www.nuget.org/packages/Deveel.Webhooks/) | -| **Deveel.Webhooks.Service** | Implements the functions to manage and resolve webhook subscriptions | [Package](https://www.nuget.org/packages/Deveel.Webhooks.Service/) | -| **Deveel.Webhooks.Service.MongoDb** | An implementation of the webhoom management data layer that is backed by [MongoDB](https://mongodb.com) databases | [Package](https://www.nuget.org/packages/Deveel.Webhooks.MongoDb/) | -| **Deveel.Webhooks.DynamicLinq** | The webhook subscription filtering engine that uses the [Dynamic LINQ](https://dynamic-linq.net/) expressions | [Package](https://www.nuget.org/packages/Deveel.Webhooks.DynamicLinq/) | +| Library | Description | NuGet | GitHub (prerelease) | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |---------------------| +| **Deveel.Webhooks** | Provides the foundation contracts of the webhook service and basic implementations for the sending functions | ![Nuget](https://img.shields.io/nuget/dt/Deveel.Webhooks?label=Deveel.Webhooks&logo=nuget) | [![GitHub](https://img.shields.io/static/v1?label=Deveel.Webhooks&message=Pre-Release&color=yellow&logo=github)](https://github.com/deveel/deveel.webhooks/pkgs/nuget/Deveel.Webhooks) | +| **Deveel.Webhooks.Service** | Implements the functions to manage and resolve webhook subscriptions | ![Nuget](https://img.shields.io/nuget/dt/Deveel.Webhooks.Service?label=Deveel.Webhooks.Service&logo=nuget)| [![GitHub](https://img.shields.io/static/v1?label=Deveel.Webhooks.Service&message=Pre-Release&color=yellow&logo=github)](https://github.com/deveel/deveel.webhooks/pkgs/nuget/Deveel.Webhooks.Service) | +| **Deveel.Webhooks.MongoDb** | An implementation of the webhoom management data layer that is backed by [MongoDB](https://mongodb.com) databases | ![Nuget](https://img.shields.io/nuget/dt/Deveel.Webhooks.MongoDb?label=Deveel.Webhooks.MongoDb&logo=nuget) | [![GitHub](https://img.shields.io/static/v1?label=Deveel.Webhooks.MongoDb&message=Pre-Release&color=yellow&logo=github)](https://github.com/deveel/deveel.webhooks/pkgs/nuget/Deveel.Webhooks.MongoDb) | +| **Deveel.Webhooks.DynamicLinq** | The webhook subscription filtering engine that uses the [Dynamic LINQ](https://dynamic-linq.net/) expressions | ![Nuget](https://img.shields.io/nuget/dt/Deveel.Webhooks.DynamicLinq?label=Deveel.Webhooks.DynamicLinq&logo=nuget) | [![GitHub](https://img.shields.io/static/v1?label=Deveel.Webhooks.DynamicLinq&message=Pre-Release&color=yellow&logo=github)](https://github.com/deveel/deveel.webhooks/pkgs/nuget/Deveel.Webhooks.DynamicLinq) | +| **Deveel.Webhooks.Receiver.AspNetCore** | An implementation of the webhook receiver that is backed by [ASP.NET Core](https://dotnet.microsoft.com/apps/aspnet) | ![Nuget](https://img.shields.io/nuget/dt/Deveel.Webhooks?label=Deveel.Webhooks.Receiver.AspNetCore&logo=nuget) | [![GitHub](https://img.shields.io/static/v1?label=Deveel.Webhooks.Receiver.AspNetCore&message=Pre-Release&color=yellow&logo=github)](https://github.com/deveel/deveel.webhooks/pkgs/nuget/Deveel.Webhooks.Receiver.AspNetCore) | You can obtain the stable versions of these libraries from the [NuGet Official](https://nuget.org) channel. -For the _nighly builds_ and previews you can restore from the [Deveel Package Manager](https://github.com/orgs/deveel/packages). +To get the latest pre-release versions of the packages you can restore from the [Deveel Package Manager](https://github.com/orgs/deveel/packages). ## Adding the Webhook Service diff --git a/src/Deveel.Webhooks.DynamicLinq/Deveel.Webhooks.DynamicLinq.csproj b/src/Deveel.Webhooks.DynamicLinq/Deveel.Webhooks.DynamicLinq.csproj index 638c3c7..efff094 100644 --- a/src/Deveel.Webhooks.DynamicLinq/Deveel.Webhooks.DynamicLinq.csproj +++ b/src/Deveel.Webhooks.DynamicLinq/Deveel.Webhooks.DynamicLinq.csproj @@ -22,7 +22,7 @@ - + True @@ -33,7 +33,11 @@ - + + + + + diff --git a/src/Deveel.Webhooks.Model/Deveel.Webhooks.Model.csproj b/src/Deveel.Webhooks.Model/Deveel.Webhooks.Model.csproj index 431aff0..4b1a61f 100644 --- a/src/Deveel.Webhooks.Model/Deveel.Webhooks.Model.csproj +++ b/src/Deveel.Webhooks.Model/Deveel.Webhooks.Model.csproj @@ -29,4 +29,8 @@ + + + + diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson new file mode 100644 index 0000000..ec181dd --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson @@ -0,0 +1,50 @@ + + + + Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson + + + + + Implements a that uses the + Newtonsoft.Json library to parse the webhook. + + + + + + Initializes a new instance of the + + An optional set of configurations that control the + behavior of the JSON serialization. When this is not provided, an instance + of is created to provide + the default settings to the serializer. + + + + Gets the settings used to configure the JSON serialization + + + + + + + + Extends the to registering + the parser for the webhook payload using the Newtonsoft.Json library. + + + + + Registers the as + a parser for webhook payloads. + + The type of webhook to parse + The builder object used to configure a webhook receiver + A set of settings to configure the JSON serialization process + + Returns the instance with the parser registered + + + + diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson.csproj b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson.csproj new file mode 100644 index 0000000..404e679 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson.csproj @@ -0,0 +1,45 @@ + + + + net6.0 + enable + enable + 1.1.6 + Deveel + true + antonello + Deveel + Copyright (C) 2021-2023 Deveel + LICENSE + deveel-logo.png + https://github.com/deveel/deveel.webhooks + git + webhooks receiver receivers aspnet core aspnetcore httpcontext httprequest newtonsoft json jsonparser + Extends the ASP.NET Core receivers with webhook parsers implemented using the Newtonsoft.Json library + https://deveel.com + .\Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson + + + + + True + + + + True + + + + + + + + + + + + + + + + diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Webhooks/NewtonsoftWebhookJsonParser.cs b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Webhooks/NewtonsoftWebhookJsonParser.cs new file mode 100644 index 0000000..009430c --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Webhooks/NewtonsoftWebhookJsonParser.cs @@ -0,0 +1,55 @@ + +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text; + +using Newtonsoft.Json; + +namespace Deveel.Webhooks { + /// + /// Implements a that uses the + /// Newtonsoft.Json library to parse the webhook. + /// + /// + public sealed class NewtonsoftWebhookJsonParser : IWebhookJsonParser { + /// + /// Initializes a new instance of the + /// + /// An optional set of configurations that control the + /// behavior of the JSON serialization. When this is not provided, an instance + /// of is created to provide + /// the default settings to the serializer. + public NewtonsoftWebhookJsonParser(JsonSerializerSettings? settings = null) { + JsonSerializerSettings = settings ?? new JsonSerializerSettings(); + } + + /// + /// Gets the settings used to configure the JSON serialization + /// + public JsonSerializerSettings JsonSerializerSettings { get; } + + /// + public async Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default) { + try { + using var textReader = new StreamReader(utf8Stream, Encoding.UTF8); + var json = await textReader.ReadToEndAsync(); + + return JsonConvert.DeserializeObject(json, JsonSerializerSettings); + } catch (Exception ex) { + throw new WebhookParseException("Could not parse the stream to a webhook", ex); + } + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Webhooks/WebhookReceiverBuilderExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Webhooks/WebhookReceiverBuilderExtensions.cs new file mode 100644 index 0000000..2729551 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Webhooks/WebhookReceiverBuilderExtensions.cs @@ -0,0 +1,46 @@ + +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Microsoft.Extensions.DependencyInjection; + +using Newtonsoft.Json; + +namespace Deveel.Webhooks { + /// + /// Extends the to registering + /// the parser for the webhook payload using the Newtonsoft.Json library. + /// + public static class WebhookReceiverBuilderExtensions { + /// + /// Registers the as + /// a parser for webhook payloads. + /// + /// The type of webhook to parse + /// The builder object used to configure a webhook receiver + /// A set of settings to configure the JSON serialization process + /// + /// Returns the instance with the parser registered + /// + public static WebhookReceiverBuilder UseNewtonsoftJsonParser(this WebhookReceiverBuilder builder, JsonSerializerSettings? settings = null) + where TWebhook : class { + + builder.Services.AddSingleton>(_ => new NewtonsoftWebhookJsonParser(settings)); + builder.Services.AddSingleton(_ => new NewtonsoftWebhookJsonParser(settings)); + + + return builder; + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj index 4f92079..5227a48 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj @@ -1,38 +1,44 @@ - - net6.0 - 1.1.6 - Deveel - true - antonello - Deveel - Copyright (C) 2021-2022 Deveel - LICENSE - deveel-logo.png - https://github.com/deveel/deveel.webhooks - git - webhooks receiver receivers aspnet core httprequest - Provides an implementation of the webhook receivers supporting the ASP.NET Core infrastructure - https://deveel.com - + + net6.0 + enable + enable + 1.1.6 + Deveel + true + antonello + Deveel + Copyright (C) 2021-2022 Deveel + LICENSE + deveel-logo.png + https://github.com/deveel/deveel.webhooks + git + webhooks receiver receivers aspnet core httprequest + Provides an implementation of the webhook receivers supporting the ASP.NET Core infrastructure + https://deveel.com + .\Deveel.Webhooks.Receiver.AspNetCore.xml + true + - - - - - - + + + + + - - - True - - - - True - - - + + + True + + + + True + + + + + + diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml new file mode 100644 index 0000000..35dbfcd --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml @@ -0,0 +1,1061 @@ + + + + Deveel.Webhooks.Receiver.AspNetCore + + + + + Extends a object to register + a receiver of a specific type of webhooks. + + + + + Adds a receiver of webhooks of a specific type to the service collection. + + The type of webhooks to receive + + The service collection to which the receiver is added + + + Returns an instance of that can + be used to further configure the receiver. + + + + + Adds a receiver of webhooks of a specific type to the service collection. + + + The type of webhooks to receive + + + The service collection to which the receiver is added + + + The path to the configuration section that contains the options for the webhook receiver + + + Returns an instance of that can + be used to further configure the receiver. + + + + + Adds a receiver of webhooks of a specific type to the service collection. + + + The type of webhooks to receive + + + The service collection to which the receiver is added + + + A configuraton action that can be used to further configure the receiver + + + Returns an instance of that can + be used to further configure the receiver. + + + + + Adds a receiver of webhooks of a specific type to the service collection. + + + The type of webhooks to receive + + + The service collection to which the receiver is added + + + A configuraton action that can be used to further configure the receiver + + + Returns an instance of that can be used to register + other services and configurations. + + + + + Extends the to provide methods + for receiving webhooks within an ASP.NET Core application request pipeline. + + + + + Adds a middleware to the application pipeline that receives webhooks + of that are posted to the given path. + + The type of the webhook to be received + The application builder instance + The relative path to listen for webhook posts + + + The middleware will listen only for POST requests to the given path using + the configurations and services registered in the application. + + + Before this middleware can be used, the webhook receiver must be registered + during the application startup. + + + + Returns the instance of the that handles + webhooks posted to the given path. + + + + + Adds a middleware to the application pipeline that provides a verification + mechanism for the webhook requests. + + The type of the webhook + The application builder instance + The HTTP method to listen for requests + The relative path to listen for verification requests + + + Some service providers require a verification of the webhook requests before + posting the webhook to the receiver: this middleware provides a mechanism to + handle such requests and respond. + + + If the provider does not require a verification, this middleware can be ignored, + and it will not affect the normal operation of the webhook receiver. + + + + Returns an instance of the that handles + the verification requests. + + + + + Adds a middleware to the application pipeline that provides a verification + mechanism for the webhook requests. + + The type of the webhook + The application builder instance + The relative path to listen for verification requests + + + By default this middleware will listen for GET requests to the given path. + + + Some service providers require a verification of the webhook requests before + posting the webhook to the receiver: this middleware provides a mechanism to + handle such requests and respond. + + + If the provider does not require a verification, this middleware can be ignored, + and it will not affect the normal operation of the webhook receiver. + + + + Returns an instance of the that handles + the verification requests. + + + + + Adds a middleware to the application pipeline that receives webhooks + of that are posted to the given path. + + The type of the webhook to be received + The application builder instance + The path to listen for webhook posts + The delegated function that is invoked by the middleware + to handle the received webhook + + + The middleware will listen only for POST requests to the given path using + the configurations registered at the application startup. + + + Any instance of the registered will + be ignored when using this middleware, and only the provided function will be + invoked by the middleware. + + + + Returns the instance of the that handles + webhooks posted to the given path. + + + + + Adds a middleware to the application pipeline that receives webhooks + of that are posted to the given path. + + The type of the webhook to be received + The application builder instance + The path to listen for webhook posts + The delegated function that is invoked by the middleware + to handle the received webhook + + + The middleware will listen only for POST requests to the given path using + the configurations registered at the application startup. + + + Any instance of the registered will + be ignored when using this middleware, and only the provided function will be + invoked by the middleware. + + + + Returns the instance of the that handles + webhooks posted to the given path. + + + + + Provides functions for handling webhooks of a specific type. + + The type of the webhook to be handled + + + The typical usage scenario of usage of services implementing this interface + is within ASP.NET receiver middlewares that are registered in the pipeline, + and resolve all compatible handlers to notify a webhook has been received by the + application. + + + It is recommended that the implementation of this interface performs a rapid + handling of the webhook, and then delegates the actual processing to a background + or external process, to avoid blocking the pipeline. + + + + + + Handles the given webhook. + + The instance of the webhook to be handled + + + Returns a that completes when the webhook has been handled. + + + + + Provides the capabilities to parse a webhook from a JSON stream. + + The type of the webhook to be parsed + + + + Parses a webhook from the given UTF-8 encoded stream. + + The UTF-8 stream that represents the binary + data of a JSON-formatted webhook + + + Returns a that completes when the webhook + stream is parsed and produces the instance of the webhook. + + + Thrown if any error occurs while parsing the webhook stream. + + + + + A service that receives a webhook from a remote source. + + The type of the webhook that is received + + + + Receives a webhook from a remote source, posted through a + HTTP request given. + + The HTTP request that transports the webhook to be received + + + + Implementations of this contract should read the content of the request and + parsing it into a webhook instance of the type . + + + Optionally the implementation may also validate the signature of the request, + to ensure that the webhook is coming from a trusted source: this is not mandatory + but highly recommended. Verification of the signatures of webhook payloads might + affect performances, since the typical implementation of signers use strong hashing + algorithms. + + + + Returns a that completes when the webhook is received + + + + + A service that is used to verify a request of acknowledgement + by the sender of a webhook, before the webhook is sent. + + + The type of webhook that is being verified + + + In several case scenarios, providers of webhooks require a verification + of the party to ensure they are the ones who should be receiving the + webhooks, and not a malicious party. + + + + + Verifies the request of acknowledgement of a webhook. + + + The HTTP request that is carrying the information + to acknowledge the webhook. + + + A token that can be used to cancel the operation + + + Returns a that indicates the result + of the verification operation. + + + + + Provides functions for the signing of a webhook payload + + + + + Gets the list of algorithms supported by this signer + + + + + Signs the given JSON body using the provided secret + as a key for the signature + + The JSON-formatted body of a webhook to sign + The secret used as a key for the signature + + A typical implementation of this method would return a string that + contains the signature, prefixed by the algorithm used to sign the + webhook in the format [algorithm]=[signature]. + + + Returns a string representing the signature of the given body + + + + + Implements a provider of instances + for given algorithms. + + The type of webhook to provide signers for + + + + Gets the signer for the given algorithm. + + The name of the algorithm handled by + the signer to lookup for + + Returns an instance of that supports + the given algorithm, or null if no such signer is available. + + + + + Defines a contract for a service that can verify the signature of a + specific type of webhook. + + The type of webhook to sign + + + Webhook signers are typically implementing the same behavior, + and this contract is a way to define a constraint usage of the signer + within a receiver context. + + + In some advanced scenarios, it is possible to have multiple signers + for the same algorithm but specific for a given type of webhook, according + to the different needs of the provider. + + + + + + + Extends the interface + to provide standard methods to retrieve the options for a specific + webhook receiver. + + + + + Gets the options for the webhook receiver of the given type. + + The type of webhook handled by the receiver + The instance of the to extend + + Returns the options for the receiver of the given type. + + + + + A default implementation of the that handles + a typical webhook signature using the SHA-256 algorithm. + + + + + + + + Computes the hash of the given using the + provided secret, + + The JSON-formatted string that represents the webhook to sign + A secret used as key for the signature + + Returns a byte array representing the hash of the given body + + + Thrown when the is null or empty + + + + + Gets the string representation of the signature, given the hash + + The byte hash of the signature + + Returns a string representing the signature of the given body + + + + + + + + Provides a default implementation of the + that is using the System.Text.Json library for parsing the JSON + representations of webhooks. + + The type of the webhook to parse + + + + Initializes a new instance of the + + A set of options to control the behavior of the serialization + + When the is not provided, a new instance of the + is created with the + default configurations. + + + + + Gets the options used to control the behavior of the serialization + + + + + + + + Extends the with + methods for the parsing of a webhooks. + + + + + Parses a webhook from the given string. + + The type of the webhook to be parsed + The instance of the to extend + The UTF-8 encoded JSON-formatted string to be parsed + + + Returns a that resolves to the parsed webhook + + + Thrown if any error occurs while parsing the webhook + + + + + An exception thrown when a webhook cannot be parsed. + + + + + + + + + + + + + + A default implementation of the + that uses the registered options and services to receive a webhook. + + The type of the webhook to receive + + + This class implements a default behavior for the , + that is based on common patterns for the processing of webhooks. + + + It is recommended to inherit from this class to implement a custom receiver behavior, + when the default behavior is not sufficient. In some case scenarios, it is recommended + to discard the possibility of using this class and implement the . + + + + + + Constructs a instance. + + An instance of the that is + used to resolve the configurations specific for this receiver. + A provider of services that + are used to verify the signature of webhooks + A parser that is used to process the JSON + content of requests and obtain instances of webhooks. By default, if this + value is null a new instance of + is created using the default options. + + + + Constructs a instance. + + The configurations used by the receiver to + process the requests + A parser that is used to process the JSON + content of requests and obtain instances of webhooks. By default, if this + value is null a new instance of + is created using the default options. + + Thrown if the given is null + + + + + Gets the options used by the receiver to process the requests. + + + + + Gets the parser used to process the JSON content of requests + + + + + Resolves a webhook signer for the given algorithm. + + The hashing algorithm used to sign the webhook + + Returns an instance of that is used to + sign the webhook, or null if no signer is available for the + given algorithm. + + + + + Signs the JSON body of a webhook using the given algorithm and secret. + + The JSON-formatted representation of a webhook + The hashing algorithm used to sign the webhook + A secret word used to compute the signature + + Returns a string that is the signature of the given JSON body, or null + if no signer is available for the given algorithm. + + + + + Parses the JSON body of a webhook request. + + The JSON-formatted body of the webhook to be parsed + + + Returns an instance of that completes the + parsing operation to obtain the webhook. + + + Thrown if the parsing operation is not supported by the receiver. + + + + + Parses the JSON body of a webhook request. + + A stream that is UTF-8 encoded and that provides the + body of the webhook to be parsed + + + Returns an instance of that completes the + parsing operation to obtain the webhook. + + + Thrown if the parsing operation is not supported by the receiver. + + + + + Attempts to get the signature from the given request. + + The HTTP request from the sender of the webhook + that should include a signature + The signature of the webhook discovered from within + the request + + By default this method verifies if the configuration of the receiver + explicitly requires or forbids the verification of signatures: in the + cases the receiver is configured not to verify signatures, this method + will return false even if the signature is present in the request. + + + Returns true if the signature was found in the request, or false otherwise. + + + + + Verifies if the given signature sent alongside a webhook is + valid for the given JSON body of the webhook itself. + + The signature sent alongside the webhook + The signing hash algorithm used to compute the signature + The JSON-formatted body of the webhook + + + The default behavior of this method is to return true if the verification + of the signature is disabled in the configuration of the receiver. + + + To verify the signature, this method will use the secret word configured as a key + to compute the signature of the given JSON body, and then compare it with the one + sent alongside the webhook. + + + + Returns true if the signature is valid for the given webhook, + or false otherwise. + + + + + Attempts to validate the webhook request. + + The HTTP request used to post the webhook + + Returns a that describes the result of the validation. + + + + + + + + Describes the result of a validation attempt. + + + + + Indicates if the signature was actually validated. + + + + + Indicates if the signature was valid, or null if the + signature was not validated. + + + + + Gets the JSON body of the webhook, or null if it was + not possible to read it from the request. + + + + + Initializes a new instance of the struct. + + The JSON-formatted string that represents the webhook + Indicates if the webhook signature was actually validated + Indicates if the webhook signature was valid + + + + An object that can be used to configure a receiver of webhooks + + The type of webhooks to receive + + When constructing the builder a set of default services are registered, + such as the middleware for the receiver and the verifier, a default JSON + parser and the default receiver service. + + + + + Initializes a new instance of the class + + + The service collection to which the receiver is added + + + Thrown if the type is not a non-abstract class + + + Thrown if the argument is null + + + + + Constructs a new instance of the class + instantiating a new service collection + + + + + Gets the service collection to which the receiver is added + + + + + Registers an implementation of the + that is used to receive the webhooks + + + The type of the receiver to use for the webhooks of type + + + Returns the current builder instance with the receiver registered + + + + + Registers an handler for the webhooks of type + that were received. + + + The type of the handler to use for the webhooks of type + + + Returns the current builder instance with the handler registered + + + + + Configures the receiver with the options from the given section path + within the configuration of the application + + + The path to the section within the configuration of the application + where the options are defined + + + Returns the current builder instance with the options configured + + + + + Configures the receiver with the given options + + + A delegate that is used to configure the options of the receiver + + + Returns the current builder instance with the options configured + + + + + Registers a parser that is used to parse the JSON body of webhooks received + + + The type of the parser to use for the webhooks of type + + + A value that specifies the lifetime of the parser service (defaults to ) + + + Returns the current builder instance with the parser registered + + + + + Registers a parser that is used to parse the JSON body of webhooks received + + + The type of the parser to use for the webhooks of type + + + An instance of the parser to use for the webhooks of type + + + Returns the current builder instance with the parser registered + + + + + Registers a default parser that is used to parse the JSON body of webhooks received + + + An optional set of options that are used to configure the JSON parser behavior + + + Returns the current builder instance with the parser registered + + + + + Registers a function as parser that is used to parse the JSON body of webhooks received + + + The function that is used to parse the JSON body of webhooks received + + + Returns the current builder instance with the parser registered + + + Thrown when the given is null + + + + + Registers a function as parser that is used to parse the JSON body of webhooks received + + + The function that is used to parse the JSON body of webhooks received + + + Returns the current builder instance with the parser registered + + + Thrown when the given is null + + + + + Registers a service that is used to sign the payload of webhooks received + + + The type of the signer to use for the webhooks of type + + + Returns the current builder instance with the signer registered + + + + + Registers a service that is used to sign the payload of webhooks received + + + The type of the signer to use for the webhooks of type + + + The instance of the signer to use for the webhooks of type + + + Returns the current builder instance with the signer registered + + + Thrown when the given is null + + + + + Describes the result of a webhook receive operation. + + + The type of webhook that was received + + + When this object is returned from a webhook receiver, it can be used + to determine if the webhook was successfully received and if the signature was valid. + + + + + Constructs a new result of a webhook receive operation. + + The webhook instance that was received, or null + if it was not possible to receive the webhook for any reason (invalid content, + missing or invalid signature, etc.) + + Whether the signature of the webhook was valid, or null if the signature + was not checked. + + + + + Gets the webhook instance that was received, or null if it was not + possible to receive the webhook for any reason (invalid content, missing or + invalid signature, etc.). + + + + + Gets whether the signature of the webhook was valid, or null if the + signature was not checked. + + + + + Implicitly converts a to a + successful result with the given webhook instance. + + + The webhook instance that was received + + + + + An exception thrown when an error occurs during the processing of a webhook + + + + + + + + + + + + + + Provides the configuration options for a webhook receiver. + + + + + Gets or sets whether the signature of the incoming webhook + should be verified. + + + + + Gets or sets the options for the signature verification. + + + + + Gets or sets the HTTP status code to return when the webhook + processing is successful (201 by default). + + + + + Gets or sets the HTTP status code to return when the webhook + processing failed for an internal error (500 by default). + + + + + Gets or sets the HTTP status code to return when the webhook + from the sender is invalid (400 by default). + + + + + Enumerates the possible locations where the signature of a webhook + can be found within a HTTP request object. + + + + + The signature is found in the HTTP header of the request. + + + + + The signature is found in the query string of the request. + + + + + Provides the configuration settings used to verify the signature + of a webhook sent to the receiver. + + + + + Gets or sets the location where the signature is found ( by default). + + + + + Gets or sets the name of the parameter that contains the signature. + + + + + Gets or sets the type of algorithm used to compute the signature. + + + + + Gets or sets the secret used to compute the signature. + + + + + Gets or sets the HTTP status code to return when the webhook + signature is invalid (400 by default). + + + + + Represents the result of a verification of a webhook request. + + + + + Constructs the result of a verification of a webhook request. + + + Whether the request is verified or not + + + An optional response to be sent back to the sender of the webhook + + + + + Gets whether the request is verified or not. + + + + + Gets an optional response to be sent back to the sender of the webhook. + + + + + Creates a new result of a successful verification of a webhook request + + + An optional response to be sent back to the sender of the webhook + + + Returns an instance of that + represents a successful verification of a webhook request. + + + + + Creates a new result of a failed verification of a webhook request + + + Returns a new instance of that + represents a failed verification of a webhook request. + + + + diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/ServiceCollectionExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/ServiceCollectionExtensions.cs index b338707..9a878e6 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/ServiceCollectionExtensions.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/ServiceCollectionExtensions.cs @@ -1,4 +1,18 @@ -using System; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; using Deveel.Webhooks; @@ -6,17 +20,88 @@ using Microsoft.Extensions.DependencyInjection.Extensions; namespace Deveel { + /// + /// Extends a object to register + /// a receiver of a specific type of webhooks. + /// public static class ServiceCollectionExtensions { - public static WebhookReceiverBuilder AddWebhooks(this IServiceCollection services) + /// + /// Adds a receiver of webhooks of a specific type to the service collection. + /// + /// The type of webhooks to receive + /// + /// The service collection to which the receiver is added + /// + /// + /// Returns an instance of that can + /// be used to further configure the receiver. + /// + public static WebhookReceiverBuilder AddWebhooks(this IServiceCollection services) where TWebhook : class { - var builder= new WebhookReceiverBuilder(typeof(TWebhook), services); + var builder = new WebhookReceiverBuilder(services); services.TryAddSingleton(builder); return builder; } - public static IServiceCollection AddWebhooks(this IServiceCollection services, Action configure) + /// + /// Adds a receiver of webhooks of a specific type to the service collection. + /// + /// + /// The type of webhooks to receive + /// + /// + /// The service collection to which the receiver is added + /// + /// + /// The path to the configuration section that contains the options for the webhook receiver + /// + /// + /// Returns an instance of that can + /// be used to further configure the receiver. + /// + public static WebhookReceiverBuilder AddWebhooks(this IServiceCollection services, string sectionPath) + where TWebhook : class + => services.AddWebhooks().Configure(sectionPath); + + /// + /// Adds a receiver of webhooks of a specific type to the service collection. + /// + /// + /// The type of webhooks to receive + /// + /// + /// The service collection to which the receiver is added + /// + /// + /// A configuraton action that can be used to further configure the receiver + /// + /// + /// Returns an instance of that can + /// be used to further configure the receiver. + /// + public static WebhookReceiverBuilder AddWebhooks(this IServiceCollection services, Action configure) + where TWebhook : class + => services.AddWebhooks().Configure(configure); + + /// + /// Adds a receiver of webhooks of a specific type to the service collection. + /// + /// + /// The type of webhooks to receive + /// + /// + /// The service collection to which the receiver is added + /// + /// + /// A configuraton action that can be used to further configure the receiver + /// + /// + /// Returns an instance of that can be used to register + /// other services and configurations. + /// + public static IServiceCollection AddWebhooks(this IServiceCollection services, Action> configure) where TWebhook : class { var builder = services.AddWebhooks(); configure?.Invoke(builder); diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs index e355ead..1b04ad4 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs @@ -1,32 +1,139 @@ -using System; -using System.Threading; -using System.Threading.Tasks; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; namespace Deveel.Webhooks { - public static class ApplicationBuilderExtensions { + /// + /// Extends the to provide methods + /// for receiving webhooks within an ASP.NET Core application request pipeline. + /// + public static class ApplicationBuilderExtensions { + /// + /// Adds a middleware to the application pipeline that receives webhooks + /// of that are posted to the given path. + /// + /// The type of the webhook to be received + /// The application builder instance + /// The relative path to listen for webhook posts + /// + /// + /// The middleware will listen only for POST requests to the given path using + /// the configurations and services registered in the application. + /// + /// + /// Before this middleware can be used, the webhook receiver must be registered + /// during the application startup. + /// + /// + /// + /// Returns the instance of the that handles + /// webhooks posted to the given path. + /// public static IApplicationBuilder UseWebhookReceiver(this IApplicationBuilder app, string path) where TWebhook : class { return app.MapWhen( context => context.Request.Method == "POST" && context.Request.Path.Equals(path), - builder => builder.UseMiddleware>() + builder => builder.UseMiddleware>() ); } - public static IApplicationBuilder UseWebhookVerifier(this IApplicationBuilder app, string method, string path) + /// + /// Adds a middleware to the application pipeline that provides a verification + /// mechanism for the webhook requests. + /// + /// The type of the webhook + /// The application builder instance + /// The HTTP method to listen for requests + /// The relative path to listen for verification requests + /// + /// + /// Some service providers require a verification of the webhook requests before + /// posting the webhook to the receiver: this middleware provides a mechanism to + /// handle such requests and respond. + /// + /// + /// If the provider does not require a verification, this middleware can be ignored, + /// and it will not affect the normal operation of the webhook receiver. + /// + /// + /// + /// Returns an instance of the that handles + /// the verification requests. + /// + public static IApplicationBuilder UseWebhookVerifier(this IApplicationBuilder app, string method, string path) where TWebhook : class => app.MapWhen( context => context.Request.Method == method && context.Request.Path.Equals(path), builder => builder.UseMiddleware>() ); - public static IApplicationBuilder UseWebhookVerfier(this IApplicationBuilder app, string path) + /// + /// Adds a middleware to the application pipeline that provides a verification + /// mechanism for the webhook requests. + /// + /// The type of the webhook + /// The application builder instance + /// The relative path to listen for verification requests + /// + /// + /// By default this middleware will listen for GET requests to the given path. + /// + /// + /// Some service providers require a verification of the webhook requests before + /// posting the webhook to the receiver: this middleware provides a mechanism to + /// handle such requests and respond. + /// + /// + /// If the provider does not require a verification, this middleware can be ignored, + /// and it will not affect the normal operation of the webhook receiver. + /// + /// + /// + /// Returns an instance of the that handles + /// the verification requests. + /// + public static IApplicationBuilder UseWebhookVerfier(this IApplicationBuilder app, string path) where TWebhook : class => app.UseWebhookVerifier("GET", path); - public static IApplicationBuilder UseWebhookReceiver(this IApplicationBuilder app, string path, Func receiver) + /// + /// Adds a middleware to the application pipeline that receives webhooks + /// of that are posted to the given path. + /// + /// The type of the webhook to be received + /// The application builder instance + /// The path to listen for webhook posts + /// The delegated function that is invoked by the middleware + /// to handle the received webhook + /// + /// + /// The middleware will listen only for POST requests to the given path using + /// the configurations registered at the application startup. + /// + /// + /// Any instance of the registered will + /// be ignored when using this middleware, and only the provided function will be + /// invoked by the middleware. + /// + /// + /// + /// Returns the instance of the that handles + /// webhooks posted to the given path. + /// + public static IApplicationBuilder UseWebhookReceiver(this IApplicationBuilder app, string path, Func receiver) where TWebhook : class { return app.MapWhen( context => context.Request.Method == "POST" && context.Request.Path.Equals(path), @@ -34,7 +141,31 @@ public static IApplicationBuilder UseWebhookReceiver(this IApplication ); } - public static IApplicationBuilder UseWebhookReceiver(this IApplicationBuilder app, string path, Action receiver) + /// + /// Adds a middleware to the application pipeline that receives webhooks + /// of that are posted to the given path. + /// + /// The type of the webhook to be received + /// The application builder instance + /// The path to listen for webhook posts + /// The delegated function that is invoked by the middleware + /// to handle the received webhook + /// + /// + /// The middleware will listen only for POST requests to the given path using + /// the configurations registered at the application startup. + /// + /// + /// Any instance of the registered will + /// be ignored when using this middleware, and only the provided function will be + /// invoked by the middleware. + /// + /// + /// + /// Returns the instance of the that handles + /// webhooks posted to the given path. + /// + public static IApplicationBuilder UseWebhookReceiver(this IApplicationBuilder app, string path, Action receiver) where TWebhook : class { return app.MapWhen( context => context.Request.Method == "POST" && context.Request.Path.Equals(path), diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookHandler.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookHandler.cs index 0159cb0..f16f610 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookHandler.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookHandler.cs @@ -1,8 +1,44 @@ -using System.Threading; -using System.Threading.Tasks; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. namespace Deveel.Webhooks { - public interface IWebhookHandler where TWebhook : class { + /// + /// Provides functions for handling webhooks of a specific type. + /// + /// The type of the webhook to be handled + /// + /// + /// The typical usage scenario of usage of services implementing this interface + /// is within ASP.NET receiver middlewares that are registered in the pipeline, + /// and resolve all compatible handlers to notify a webhook has been received by the + /// application. + /// + /// + /// It is recommended that the implementation of this interface performs a rapid + /// handling of the webhook, and then delegates the actual processing to a background + /// or external process, to avoid blocking the pipeline. + /// + /// + public interface IWebhookHandler where TWebhook : class { + /// + /// Handles the given webhook. + /// + /// The instance of the webhook to be handled + /// + /// + /// Returns a that completes when the webhook has been handled. + /// Task HandleAsync(TWebhook webhook, CancellationToken cancellationToken = default); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookJsonParser.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookJsonParser.cs index d975374..ae823aa 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookJsonParser.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookJsonParser.cs @@ -1,10 +1,36 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. namespace Deveel.Webhooks { - public interface IWebhookJsonParser { - Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default); + /// + /// Provides the capabilities to parse a webhook from a JSON stream. + /// + /// The type of the webhook to be parsed + public interface IWebhookJsonParser { + /// + /// Parses a webhook from the given UTF-8 encoded stream. + /// + /// The UTF-8 stream that represents the binary + /// data of a JSON-formatted webhook + /// + /// + /// Returns a that completes when the webhook + /// stream is parsed and produces the instance of the webhook. + /// + /// + /// Thrown if any error occurs while parsing the webhook stream. + /// + Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookReceiver.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookReceiver.cs index 2d4cb09..5268418 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookReceiver.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookReceiver.cs @@ -1,4 +1,4 @@ -// Copyright 2022 Deveel +// Copyright 2022-2023 Deveel // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,14 +12,36 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; -using System.Threading.Tasks; -using System.Threading; - using Microsoft.AspNetCore.Http; namespace Deveel.Webhooks { - public interface IWebhookReceiver where TWebhook : class { - Task> ReceiveAsync(HttpRequest request, CancellationToken cancellationToken); + /// + /// A service that receives a webhook from a remote source. + /// + /// The type of the webhook that is received + public interface IWebhookReceiver where TWebhook : class { + /// + /// Receives a webhook from a remote source, posted through a + /// HTTP request given. + /// + /// The HTTP request that transports the webhook to be received + /// + /// + /// + /// Implementations of this contract should read the content of the request and + /// parsing it into a webhook instance of the type . + /// + /// + /// Optionally the implementation may also validate the signature of the request, + /// to ensure that the webhook is coming from a trusted source: this is not mandatory + /// but highly recommended. Verification of the signatures of webhook payloads might + /// affect performances, since the typical implementation of signers use strong hashing + /// algorithms. + /// + /// + /// + /// Returns a that completes when the webhook is received + /// + Task> ReceiveAsync(HttpRequest request, CancellationToken cancellationToken); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs index c7422d1..1dce865 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs @@ -1,10 +1,47 @@ -using System; -using System.Threading.Tasks; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. using Microsoft.AspNetCore.Http; namespace Deveel.Webhooks { - public interface IWebhookRequestVerifier { - Task VerifyRequestAsync(HttpContext context); + /// + /// A service that is used to verify a request of acknowledgement + /// by the sender of a webhook, before the webhook is sent. + /// + /// + /// The type of webhook that is being verified + /// + /// + /// In several case scenarios, providers of webhooks require a verification + /// of the party to ensure they are the ones who should be receiving the + /// webhooks, and not a malicious party. + /// + public interface IWebhookRequestVerifier { + /// + /// Verifies the request of acknowledgement of a webhook. + /// + /// + /// The HTTP request that is carrying the information + /// to acknowledge the webhook. + /// + /// + /// A token that can be used to cancel the operation + /// + /// + /// Returns a that indicates the result + /// of the verification operation. + /// + Task VerifyRequestAsync(HttpRequest httpRequest, CancellationToken cancellationToken = default); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner.cs index dcf5be5..c3d484d 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner.cs @@ -1,9 +1,43 @@ -using System; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; namespace Deveel.Webhooks { + /// + /// Provides functions for the signing of a webhook payload + /// public interface IWebhookSigner { + /// + /// Gets the list of algorithms supported by this signer + /// string[] Algorithms { get; } + /// + /// Signs the given JSON body using the provided secret + /// as a key for the signature + /// + /// The JSON-formatted body of a webhook to sign + /// The secret used as a key for the signature + /// + /// A typical implementation of this method would return a string that + /// contains the signature, prefixed by the algorithm used to sign the + /// webhook in the format [algorithm]=[signature]. + /// + /// + /// Returns a string representing the signature of the given body + /// string SignWebhook(string jsonBody, string secret); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSignerProvider.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSignerProvider.cs index f87b71f..52ace28 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSignerProvider.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSignerProvider.cs @@ -1,5 +1,33 @@ -namespace Deveel.Webhooks { +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Deveel.Webhooks { + /// + /// Implements a provider of instances + /// for given algorithms. + /// + /// The type of webhook to provide signers for public interface IWebhookSignerProvider { - IWebhookSigner GetSigner(string algorithm); + /// + /// Gets the signer for the given algorithm. + /// + /// The name of the algorithm handled by + /// the signer to lookup for + /// + /// Returns an instance of that supports + /// the given algorithm, or null if no such signer is available. + /// + IWebhookSigner? GetSigner(string algorithm); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner_1.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner_1.cs index 2f5ce59..3b0058a 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner_1.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner_1.cs @@ -1,6 +1,38 @@ -using System; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; namespace Deveel.Webhooks { - public interface IWebhookSigner : IWebhookSigner where TWebhook : class { + /// + /// Defines a contract for a service that can verify the signature of a + /// specific type of webhook. + /// + /// The type of webhook to sign + /// + /// + /// Webhook signers are typically implementing the same behavior, + /// and this contract is a way to define a constraint usage of the signer + /// within a receiver context. + /// + /// + /// In some advanced scenarios, it is possible to have multiple signers + /// for the same algorithm but specific for a given type of webhook, according + /// to the different needs of the provider. + /// + /// + /// + public interface IWebhookSigner : IWebhookSigner where TWebhook : class { } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/NewtonsoftWebhookJsonParser.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/NewtonsoftWebhookJsonParser.cs deleted file mode 100644 index fcfeed3..0000000 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/NewtonsoftWebhookJsonParser.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.IO; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -using Newtonsoft.Json; - -namespace Deveel.Webhooks { - public sealed class NewtonsoftWebhookJsonParser : IWebhookJsonParser { - public NewtonsoftWebhookJsonParser(JsonSerializerSettings settings = null) { - JsonSerializerSettings = settings ?? new JsonSerializerSettings(); - } - - public JsonSerializerSettings JsonSerializerSettings { get; } - - public async Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default) { - try { - using var textReader = new StreamReader(utf8Stream, Encoding.UTF8); - var json = await textReader.ReadToEndAsync(); - - return JsonConvert.DeserializeObject(json, JsonSerializerSettings); - } catch (Exception ex) { - throw new WebhookParseException("Could not parse the stream to a webhook", ex); - } - } - } -} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/OptionsSnapshotExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/OptionsSnapshotExtensions.cs new file mode 100644 index 0000000..f31062a --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/OptionsSnapshotExtensions.cs @@ -0,0 +1,35 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Microsoft.Extensions.Options; + +namespace Deveel.Webhooks { + /// + /// Extends the interface + /// to provide standard methods to retrieve the options for a specific + /// webhook receiver. + /// + public static class OptionsSnapshotExtensions { + /// + /// Gets the options for the webhook receiver of the given type. + /// + /// The type of webhook handled by the receiver + /// The instance of the to extend + /// + /// Returns the options for the receiver of the given type. + /// + public static WebhookReceiverOptions GetReceiverOptions(this IOptionsSnapshot options) + => options.Get(typeof(TWebhook).Name); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/Sha256WebhookSigner.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/Sha256WebhookSigner.cs index 3f790b2..8363813 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/Sha256WebhookSigner.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/Sha256WebhookSigner.cs @@ -1,11 +1,41 @@ -using System; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using System.Security.Cryptography; using System.Text; namespace Deveel.Webhooks { - public class Sha256WebhookSigner : IWebhookSigner { + /// + /// A default implementation of the that handles + /// a typical webhook signature using the SHA-256 algorithm. + /// + public class Sha256WebhookSigner : IWebhookSigner { + /// public virtual string[] Algorithms => new[] { "sha256", "sha-256" }; + /// + /// Computes the hash of the given using the + /// provided secret, + /// + /// The JSON-formatted string that represents the webhook to sign + /// A secret used as key for the signature + /// + /// Returns a byte array representing the hash of the given body + /// + /// + /// Thrown when the is null or empty + /// protected virtual byte[] ComputeHash(string jsonBody, string secret) { if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentException($"'{nameof(secret)}' cannot be null or whitespace.", nameof(secret)); @@ -16,10 +46,18 @@ protected virtual byte[] ComputeHash(string jsonBody, string secret) { return sha256.ComputeHash(Encoding.UTF8.GetBytes(jsonBody)); } + /// + /// Gets the string representation of the signature, given the hash + /// + /// The byte hash of the signature + /// + /// Returns a string representing the signature of the given body + /// protected virtual string GetSignatureString(byte[] hash) { return $"{Algorithms[0]}={Convert.ToBase64String(hash)}"; } + /// public virtual string SignWebhook(string jsonBody, string secret) { if (string.IsNullOrWhiteSpace(jsonBody)) throw new ArgumentException($"'{nameof(jsonBody)}' cannot be null or whitespace.", nameof(jsonBody)); diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/SystemTextWebhookJsonParser.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/SystemTextWebhookJsonParser.cs index c240908..9c729a8 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/SystemTextWebhookJsonParser.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/SystemTextWebhookJsonParser.cs @@ -1,18 +1,47 @@ -using System; -using System.IO; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; namespace Deveel.Webhooks { - public sealed class SystemTextWebhookJsonParser : IWebhookJsonParser where TWebhook : class { - public SystemTextWebhookJsonParser(JsonSerializerOptions options = null) { + /// + /// Provides a default implementation of the + /// that is using the System.Text.Json library for parsing the JSON + /// representations of webhooks. + /// + /// The type of the webhook to parse + public sealed class SystemTextWebhookJsonParser : IWebhookJsonParser where TWebhook : class { + /// + /// Initializes a new instance of the + /// + /// A set of options to control the behavior of the serialization + /// + /// When the is not provided, a new instance of the + /// is created with the + /// default configurations. + /// + public SystemTextWebhookJsonParser(JsonSerializerOptions? options = null) { JsonSerializerOptions = options ?? new JsonSerializerOptions(); } + /// + /// Gets the options used to control the behavior of the serialization + /// public JsonSerializerOptions JsonSerializerOptions { get; } - public async Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default) { + /// + public async Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default) { try { return await JsonSerializer.DeserializeAsync(utf8Stream, JsonSerializerOptions, cancellationToken); } catch (Exception ex) { diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs index 691b573..e462a3a 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs @@ -1,26 +1,36 @@ -using System; -using System.Threading; -using System.Threading.Tasks; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; namespace Deveel.Webhooks { - class WebhookDelegatedReceiverMiddleware where TWebhook : class { + class WebhookDelegatedReceiverMiddleware where TWebhook : class { private readonly RequestDelegate next; - private readonly Func asyncHandler; - private readonly Action syncHandler; + private readonly Func? asyncHandler; + private readonly Action? syncHandler; public WebhookDelegatedReceiverMiddleware(RequestDelegate next, Func handler) { this.next = next; - this.asyncHandler = handler; + asyncHandler = handler; } public WebhookDelegatedReceiverMiddleware(RequestDelegate next, Action handler) { this.next = next; - this.syncHandler = handler; + syncHandler = handler; } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookException.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookException.cs deleted file mode 100644 index 755eeed..0000000 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookException.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; - -namespace Deveel.Webhooks { - public class WebhookException : Exception { - public WebhookException(string message, Exception innerException) - : base(message, innerException) { - } - - public WebhookException(string message) - : base(message) { - } - - public WebhookException() { - - } - } -} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookJsonParserExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookJsonParserExtensions.cs new file mode 100644 index 0000000..deaba08 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookJsonParserExtensions.cs @@ -0,0 +1,45 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text; + +namespace Deveel.Webhooks { + /// + /// Extends the with + /// methods for the parsing of a webhooks. + /// + public static class WebhookJsonParserExtensions { + /// + /// Parses a webhook from the given string. + /// + /// The type of the webhook to be parsed + /// The instance of the to extend + /// The UTF-8 encoded JSON-formatted string to be parsed + /// + /// + /// Returns a that resolves to the parsed webhook + /// + /// + /// Thrown if any error occurs while parsing the webhook + /// + public static async Task ParseWebhookAsync(this IWebhookJsonParser parser, string? json, CancellationToken cancellationToken = default) + where TWebhook : class { + if (string.IsNullOrWhiteSpace(json)) + return null; + + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + return await parser.ParseWebhookAsync(stream, cancellationToken); + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookParseException.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookParseException.cs index e333f41..c5f8845 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookParseException.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookParseException.cs @@ -1,14 +1,34 @@ -using System; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; namespace Deveel.Webhooks { - public sealed class WebhookParseException : WebhookException { + /// + /// An exception thrown when a webhook cannot be parsed. + /// + public sealed class WebhookParseException : WebhookReceiverException { + /// public WebhookParseException() { } - public WebhookParseException(string message) : base(message) { + /// + public WebhookParseException(string message) : base(message) { } - public WebhookParseException(string message, Exception innerException) : base(message, innerException) { + /// + public WebhookParseException(string message, Exception innerException) : base(message, innerException) { } } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs index 94dfda5..ca59353 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs @@ -1,15 +1,65 @@ -namespace Deveel.Webhooks { +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Deveel.Webhooks { + /// + /// Describes the result of a webhook receive operation. + /// + /// + /// The type of webhook that was received + /// + /// + /// When this object is returned from a webhook receiver, it can be used + /// to determine if the webhook was successfully received and if the signature was valid. + /// public readonly struct WebhookReceiveResult where TWebhook : class { - public WebhookReceiveResult(TWebhook webhook, bool? signatureValid) : this() { + /// + /// Constructs a new result of a webhook receive operation. + /// + /// The webhook instance that was received, or null + /// if it was not possible to receive the webhook for any reason (invalid content, + /// missing or invalid signature, etc.) + /// + /// Whether the signature of the webhook was valid, or null if the signature + /// was not checked. + /// + public WebhookReceiveResult(TWebhook? webhook, bool? signatureValid) : this() { Webhook = webhook; SignatureValid = signatureValid; } - public TWebhook Webhook { get; } + /// + /// Gets the webhook instance that was received, or null if it was not + /// possible to receive the webhook for any reason (invalid content, missing or + /// invalid signature, etc.). + /// + public TWebhook? Webhook { get; } + /// + /// Gets whether the signature of the webhook was valid, or null if the + /// signature was not checked. + /// public bool? SignatureValid { get; } - public static implicit operator WebhookReceiveResult(TWebhook webhook) + /// + /// Implicitly converts a to a + /// successful result with the given webhook instance. + /// + /// + /// The webhook instance that was received + /// + public static implicit operator WebhookReceiveResult(TWebhook? webhook) => new WebhookReceiveResult(webhook, null); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs index ff77e46..afb2215 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs @@ -1,67 +1,186 @@ -using System; -using System.IO; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Diagnostics.CodeAnalysis; using System.Text; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; namespace Deveel.Webhooks { - public partial class WebhookReceiver : IWebhookReceiver + /// + /// A default implementation of the + /// that uses the registered options and services to receive a webhook. + /// + /// The type of the webhook to receive + /// + /// + /// This class implements a default behavior for the , + /// that is based on common patterns for the processing of webhooks. + /// + /// + /// It is recommended to inherit from this class to implement a custom receiver behavior, + /// when the default behavior is not sufficient. In some case scenarios, it is recommended + /// to discard the possibility of using this class and implement the . + /// + /// + public class WebhookReceiver : IWebhookReceiver where TWebhook : class { - private readonly IWebhookSignerProvider signerProvider; - - public WebhookReceiver(IOptions> options, - IWebhookSignerProvider signerProvider = null, - IWebhookJsonParser jsonParser = null) - : this(options.Value, jsonParser) { + private readonly IWebhookSignerProvider? signerProvider; + + /// + /// Constructs a instance. + /// + /// An instance of the that is + /// used to resolve the configurations specific for this receiver. + /// A provider of services that + /// are used to verify the signature of webhooks + /// A parser that is used to process the JSON + /// content of requests and obtain instances of webhooks. By default, if this + /// value is null a new instance of + /// is created using the default options. + public WebhookReceiver(IOptionsSnapshot options, + IWebhookJsonParser? jsonParser = null, + IWebhookSignerProvider? signerProvider = null) + : this(options.GetReceiverOptions(), jsonParser) { this.signerProvider = signerProvider; } - protected WebhookReceiver(WebhookReceiverOptions receiverOptions, IWebhookJsonParser jsonParser) { - ReceiverOptions = receiverOptions ?? throw new ArgumentNullException(nameof(receiverOptions)); - JsonParser = jsonParser; + /// + /// Constructs a instance. + /// + /// The configurations used by the receiver to + /// process the requests + /// A parser that is used to process the JSON + /// content of requests and obtain instances of webhooks. By default, if this + /// value is null a new instance of + /// is created using the default options. + /// + /// Thrown if the given is null + /// + protected WebhookReceiver(WebhookReceiverOptions options, IWebhookJsonParser? jsonParser) { + ReceiverOptions = options ?? throw new ArgumentNullException(nameof(options)); + JsonParser = jsonParser ?? new SystemTextWebhookJsonParser(); } - protected WebhookReceiverOptions ReceiverOptions { get; } - - protected IWebhookJsonParser JsonParser { get; } - - protected virtual IWebhookSigner GetSigner(string algorithm) { + /// + /// Gets the options used by the receiver to process the requests. + /// + protected virtual WebhookReceiverOptions ReceiverOptions { get; } + + /// + /// Gets the parser used to process the JSON content of requests + /// + protected virtual IWebhookJsonParser JsonParser { get; } + + /// + /// Resolves a webhook signer for the given algorithm. + /// + /// The hashing algorithm used to sign the webhook + /// + /// Returns an instance of that is used to + /// sign the webhook, or null if no signer is available for the + /// given algorithm. + /// + protected virtual IWebhookSigner? GetSigner(string algorithm) { return signerProvider?.GetSigner(algorithm); } - protected virtual string SignWebhook(string jsonBody, string algorithm, string secret) { - var signer = GetSigner(algorithm); - if (signer == null) - return null; - - return signer.SignWebhook(jsonBody, secret); + /// + /// Signs the JSON body of a webhook using the given algorithm and secret. + /// + /// The JSON-formatted representation of a webhook + /// The hashing algorithm used to sign the webhook + /// A secret word used to compute the signature + /// + /// Returns a string that is the signature of the given JSON body, or null + /// if no signer is available for the given algorithm. + /// + protected virtual string? SignWebhook(string jsonBody, string algorithm, string secret) { + return GetSigner(algorithm)?.SignWebhook(jsonBody, secret); } - protected virtual async Task ParseJsonAsync(string jsonBody, CancellationToken cancellationToken) { - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonBody)); - return await ParseJsonAsync(stream, cancellationToken); + /// + /// Parses the JSON body of a webhook request. + /// + /// The JSON-formatted body of the webhook to be parsed + /// + /// + /// Returns an instance of that completes the + /// parsing operation to obtain the webhook. + /// + /// + /// Thrown if the parsing operation is not supported by the receiver. + /// + protected virtual async Task ParseJsonAsync(string? jsonBody, CancellationToken cancellationToken) { + if (JsonParser == null) + throw new NotSupportedException("The JSON parser was not provided"); + + return await JsonParser.ParseWebhookAsync(jsonBody, cancellationToken); } - protected virtual async Task ParseJsonAsync(Stream utf8Stream, CancellationToken cancellationToken) { + /// + /// Parses the JSON body of a webhook request. + /// + /// A stream that is UTF-8 encoded and that provides the + /// body of the webhook to be parsed + /// + /// + /// Returns an instance of that completes the + /// parsing operation to obtain the webhook. + /// + /// + /// Thrown if the parsing operation is not supported by the receiver. + /// + protected virtual async Task ParseJsonAsync(Stream utf8Stream, CancellationToken cancellationToken) { if (JsonParser == null) throw new NotSupportedException("The JSON parser was not provided"); return await JsonParser.ParseWebhookAsync(utf8Stream, cancellationToken); } - private int InvalidSignatureStatusCode() => ReceiverOptions.Signature?.InvalidStatusCode ?? 400; + private string? GetAlgorithm(string signature) { + var index = signature.IndexOf('='); + if (index == -1) + return ReceiverOptions?.Signature?.Algorithm; + + return signature.Substring(0, index); + } private bool ValidateSignature() => (ReceiverOptions.VerifySignature ?? false) && ReceiverOptions.Signature != null && !String.IsNullOrWhiteSpace(ReceiverOptions.Signature.ParameterName) && - !String.IsNullOrWhiteSpace(ReceiverOptions.Signature.Secret) && - !String.IsNullOrWhiteSpace(ReceiverOptions.Signature.Algorithm); - - protected virtual bool TryGetSignature(HttpRequest request, out string signature) { + !String.IsNullOrWhiteSpace(ReceiverOptions.Signature.Secret); + + /// + /// Attempts to get the signature from the given request. + /// + /// The HTTP request from the sender of the webhook + /// that should include a signature + /// The signature of the webhook discovered from within + /// the request + /// + /// By default this method verifies if the configuration of the receiver + /// explicitly requires or forbids the verification of signatures: in the + /// cases the receiver is configured not to verify signatures, this method + /// will return false even if the signature is present in the request. + /// + /// + /// Returns true if the signature was found in the request, or false otherwise. + /// + protected virtual bool TryGetSignature(HttpRequest request, [MaybeNullWhen(false)] out string? signature) { if (!ValidateSignature()) { signature = null; return false; @@ -89,30 +208,68 @@ protected virtual bool TryGetSignature(HttpRequest request, out string signature return false; } + /// + /// Verifies if the given signature sent alongside a webhook is + /// valid for the given JSON body of the webhook itself. + /// + /// The signature sent alongside the webhook + /// The signing hash algorithm used to compute the signature + /// The JSON-formatted body of the webhook + /// + /// + /// The default behavior of this method is to return true if the verification + /// of the signature is disabled in the configuration of the receiver. + /// + /// + /// To verify the signature, this method will use the secret word configured as a key + /// to compute the signature of the given JSON body, and then compare it with the one + /// sent alongside the webhook. + /// + /// + /// + /// Returns true if the signature is valid for the given webhook, + /// or false otherwise. + /// protected virtual bool IsSignatureValid(string signature, string algorithm, string jsonBody) { - if (!(ReceiverOptions.VerifySignature ?? false)) + if (!ValidateSignature()) return true; + if (String.IsNullOrWhiteSpace(ReceiverOptions?.Signature?.Secret)) + return false; + var computedSignature = SignWebhook(jsonBody, algorithm, ReceiverOptions.Signature.Secret); if (String.IsNullOrWhiteSpace(computedSignature)) return false; - return String.Equals(computedSignature, signature, StringComparison.OrdinalIgnoreCase); + return String.Equals(computedSignature, signature, StringComparison.Ordinal); } + /// + /// Attempts to validate the webhook request. + /// + /// The HTTP request used to post the webhook + /// + /// Returns a that describes the result of the validation. + /// protected async Task TryValidateWebhook(HttpRequest request) { using var reader = new StreamReader(request.Body, Encoding.UTF8); var jsonBody = await reader.ReadToEndAsync(); if (!ValidateSignature() || - !TryGetSignature(request, out var signature)) + !TryGetSignature(request, out var signature) || + String.IsNullOrWhiteSpace(signature)) return new ValidateResult(jsonBody, false, null); - var isValid = IsSignatureValid(signature, ReceiverOptions.Signature.Algorithm, jsonBody); + var algorithm = GetAlgorithm(signature); + if (String.IsNullOrWhiteSpace(algorithm)) + return new ValidateResult(jsonBody, true, false); + + var isValid = IsSignatureValid(signature, algorithm, jsonBody); return new ValidateResult(jsonBody, true, isValid); } + /// public virtual async Task> ReceiveAsync(HttpRequest request, CancellationToken cancellationToken) { if (String.IsNullOrWhiteSpace(request.ContentType) || !request.ContentType.StartsWith("application/json")) @@ -135,21 +292,41 @@ public virtual async Task> ReceiveAsync(HttpReque } else { return await ParseJsonAsync(request.Body, cancellationToken); } - } catch (WebhookException) { + } catch (WebhookReceiverException) { throw; } catch(Exception ex) { - throw new WebhookException("Could not receive the webhook", ex); + throw new WebhookReceiverException("Could not receive the webhook", ex); } } + /// + /// Describes the result of a validation attempt. + /// protected readonly struct ValidateResult { + /// + /// Indicates if the signature was actually validated. + /// public bool SignatureValidated { get; } + /// + /// Indicates if the signature was valid, or null if the + /// signature was not validated. + /// public bool? IsValid { get; } - public string JsonBody { get; } - - public ValidateResult(string jsonBody, bool validated, bool? isValid) : this() { + /// + /// Gets the JSON body of the webhook, or null if it was + /// not possible to read it from the request. + /// + public string? JsonBody { get; } + + /// + /// Initializes a new instance of the struct. + /// + /// The JSON-formatted string that represents the webhook + /// Indicates if the webhook signature was actually validated + /// Indicates if the webhook signature was valid + public ValidateResult(string? jsonBody, bool validated, bool? isValid) : this() { JsonBody = jsonBody; SignatureValidated = validated; IsValid = isValid; diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs index 3dbd4b6..1e9a6b5 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs @@ -1,31 +1,54 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Runtime.CompilerServices; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using System.Text; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Options; - -using Newtonsoft.Json; namespace Deveel.Webhooks { - public sealed class WebhookReceiverBuilder { - public WebhookReceiverBuilder(Type webhookType, IServiceCollection services) { - if (webhookType == null) - throw new ArgumentNullException(nameof(webhookType)); - - if (!webhookType.IsClass || webhookType.IsAbstract) + /// + /// An object that can be used to configure a receiver of webhooks + /// + /// The type of webhooks to receive + /// + /// When constructing the builder a set of default services are registered, + /// such as the middleware for the receiver and the verifier, a default JSON + /// parser and the default receiver service. + /// + public sealed class WebhookReceiverBuilder where TWebhook : class { + /// + /// Initializes a new instance of the class + /// + /// + /// The service collection to which the receiver is added + /// + /// + /// Thrown if the type is not a non-abstract class + /// + /// + /// Thrown if the argument is null + /// + public WebhookReceiverBuilder(IServiceCollection services) { + if (!typeof(TWebhook).IsClass || typeof(TWebhook).IsAbstract) throw new ArgumentException("The webhook type must be a non-abstract class"); - WebhookType = webhookType; Services = services ?? throw new ArgumentNullException(nameof(services)); + Services.TryAddSingleton(this); + RegisterReceiverMiddleware(); RegisterVerifierMiddleware(); RegisterDefaultReceiver(); @@ -33,48 +56,67 @@ public WebhookReceiverBuilder(Type webhookType, IServiceCollection services) { UseJsonParser(); } - public Type WebhookType { get; } - + /// + /// Constructs a new instance of the class + /// instantiating a new service collection + /// + public WebhookReceiverBuilder() + : this(new ServiceCollection()) { + } + + /// + /// Gets the service collection to which the receiver is added + /// public IServiceCollection Services { get; } private void RegisterReceiverMiddleware() { - var middlewareType = typeof(WebhookRceiverMiddleware<>).MakeGenericType(WebhookType); - Services.AddScoped(middlewareType); + Services.AddScoped>(); } private void RegisterVerifierMiddleware() { - var middlewareType = typeof(WebhookRequestVerfierMiddleware<>).MakeGenericType(WebhookType); - Services.AddScoped(middlewareType); + Services.AddScoped>(); } private void RegisterDefaultReceiver() { - var receiverType = typeof(IWebhookReceiver<>).MakeGenericType(WebhookType); - var defaultReceiverType = typeof(WebhookReceiver<>).MakeGenericType(WebhookType); - - Services.TryAddScoped(receiverType, defaultReceiverType); - Services.TryAddScoped(defaultReceiverType, defaultReceiverType); + Services.TryAddScoped, WebhookReceiver>(); + Services.TryAddScoped>(); } - public WebhookReceiverBuilder UseReceiver() { - var receiverType = typeof(IWebhookReceiver<>).MakeGenericType(WebhookType); - if (!receiverType.IsAssignableFrom(typeof(TReceiver))) - throw new ArgumentException($"The type '{typeof(TReceiver)}' must be assignable from '{receiverType}'"); - - Services.RemoveAll(receiverType); - Services.AddScoped(receiverType, typeof(TReceiver)); - - if (typeof(TReceiver).IsClass && !typeof(TReceiver).IsAbstract) + /// + /// Registers an implementation of the + /// that is used to receive the webhooks + /// + /// + /// The type of the receiver to use for the webhooks of type + /// + /// + /// Returns the current builder instance with the receiver registered + /// + public WebhookReceiverBuilder UseReceiver() + where TReceiver : class, IWebhookReceiver { + + Services.AddScoped, TReceiver>(); + + if (!typeof(TReceiver).IsAbstract) Services.AddScoped(typeof(TReceiver), typeof(TReceiver)); return this; } - public WebhookReceiverBuilder AddHandler() { - var handlerType = typeof(IWebhookHandler<>).MakeGenericType(WebhookType); - if (!handlerType.IsAssignableFrom(typeof(THandler))) - throw new ArgumentException($"The type '{typeof(THandler)}' must be assignable from '{handlerType}'"); - - Services.AddScoped(handlerType, typeof(THandler)); + /// + /// Registers an handler for the webhooks of type + /// that were received. + /// + /// + /// The type of the handler to use for the webhooks of type + /// + /// + /// Returns the current builder instance with the handler registered + /// + public WebhookReceiverBuilder AddHandler() + where THandler : class, IWebhookHandler { + + Services.AddScoped, THandler>(); if (typeof(THandler).IsClass && !typeof(THandler).IsAbstract) Services.AddScoped(typeof(THandler), typeof(THandler)); @@ -82,38 +124,58 @@ public WebhookReceiverBuilder AddHandler() { return this; } - public WebhookReceiverBuilder ConfigureOptions(string sectionName) where TOptions : class { - var optionType = typeof(WebhookReceiverOptions<>).MakeGenericType(WebhookType); - if (!optionType.IsAssignableFrom(typeof(TOptions))) - throw new ArgumentException($"The options type '{typeof(TOptions)}' is not assignable from '{optionType}'"); - + /// + /// Configures the receiver with the options from the given section path + /// within the configuration of the application + /// + /// + /// The path to the section within the configuration of the application + /// where the options are defined + /// + /// + /// Returns the current builder instance with the options configured + /// + public WebhookReceiverBuilder Configure(string sectionPath) { // TODO: Validate the configured options - Services.AddOptions() - .BindConfiguration(sectionName); + Services.AddOptions(typeof(TWebhook).Name) + .BindConfiguration(sectionPath); return this; } - public WebhookReceiverBuilder ConfigureOptions(Action configure) where TOptions : class { - var optionType = typeof(WebhookReceiverOptions<>).MakeGenericType(WebhookType); - if (!optionType.IsAssignableFrom(typeof(TOptions))) - throw new ArgumentException($"The options type '{typeof(TOptions)}' is not assignable to '{optionType}'"); - + /// + /// Configures the receiver with the given options + /// + /// + /// A delegate that is used to configure the options of the receiver + /// + /// + /// Returns the current builder instance with the options configured + /// + public WebhookReceiverBuilder Configure(Action configure) { // TODO: Validate the configured options - Services.AddOptions() + Services.AddOptions(typeof(TWebhook).Name) .Configure(configure); return this; } - public WebhookReceiverBuilder UseJsonParser(ServiceLifetime lifetime = ServiceLifetime.Singleton) { - var parserType = typeof(IWebhookJsonParser<>).MakeGenericType(WebhookType); - - if (!parserType.IsAssignableFrom(typeof(TParser))) - throw new ArgumentException($"The type '{typeof(TParser)}' is not assignable to '{parserType}'"); - - Services.RemoveAll(parserType); - Services.Add(new ServiceDescriptor(parserType, typeof(TParser), lifetime)); + /// + /// Registers a parser that is used to parse the JSON body of webhooks received + /// + /// + /// The type of the parser to use for the webhooks of type + /// + /// + /// A value that specifies the lifetime of the parser service (defaults to ) + /// + /// + /// Returns the current builder instance with the parser registered + /// + public WebhookReceiverBuilder UseJsonParser(ServiceLifetime lifetime = ServiceLifetime.Singleton) + where TParser : class, IWebhookJsonParser { + + Services.Add(new ServiceDescriptor(typeof(IWebhookJsonParser), typeof(TParser), lifetime)); if (typeof(TParser).IsClass && !typeof(TParser).IsAbstract) Services.Add(new ServiceDescriptor(typeof(TParser), typeof(TParser), lifetime)); @@ -121,111 +183,144 @@ public WebhookReceiverBuilder UseJsonParser(ServiceLifetime lifetime = return this; } - public WebhookReceiverBuilder UseJsonParser(TParser parser) - where TParser : class { - var parserType = typeof(IWebhookJsonParser<>).MakeGenericType(WebhookType); - - if (!parserType.IsAssignableFrom(typeof(TParser))) - throw new ArgumentException($"The type '{typeof(TParser)}' is not assignable to '{parserType}'"); - - Services.RemoveAll(parserType); - Services.AddSingleton(parserType, parser); - + /// + /// Registers a parser that is used to parse the JSON body of webhooks received + /// + /// + /// The type of the parser to use for the webhooks of type + /// + /// + /// An instance of the parser to use for the webhooks of type + /// + /// + /// Returns the current builder instance with the parser registered + /// + public WebhookReceiverBuilder UseJsonParser(TParser parser) + where TParser : class, IWebhookJsonParser { + Services.AddSingleton(typeof(IWebhookJsonParser), parser); Services.AddSingleton(parser); return this; } - public WebhookReceiverBuilder UseJsonParser(JsonSerializerOptions options = null) { - var parserType = typeof(IWebhookJsonParser<>).MakeGenericType(WebhookType); - var jsonParserType = typeof(SystemTextWebhookJsonParser<>).MakeGenericType(WebhookType); - var parser = Activator.CreateInstance(jsonParserType, new[] {options}); - - Services.RemoveAll(parserType); - Services.AddSingleton(parserType, parser); - Services.AddSingleton(jsonParserType, parser); - - return this; - } - - public WebhookReceiverBuilder UseNewtonsoftJsonParser(JsonSerializerSettings settings = null) { - var parserType = typeof(IWebhookJsonParser<>).MakeGenericType(WebhookType); - var jsonParserType = typeof(NewtonsoftWebhookJsonParser<>).MakeGenericType(WebhookType); - var parser = Activator.CreateInstance(jsonParserType, new[] { settings }); - - Services.RemoveAll(parserType); - Services.AddSingleton(parserType, parser); - Services.AddSingleton(jsonParserType, parser); + /// + /// Registers a default parser that is used to parse the JSON body of webhooks received + /// + /// + /// An optional set of options that are used to configure the JSON parser behavior + /// + /// + /// Returns the current builder instance with the parser registered + /// + public WebhookReceiverBuilder UseJsonParser(JsonSerializerOptions? options = null) { + Services.AddSingleton>(_ => new SystemTextWebhookJsonParser(options)); + Services.AddSingleton(_ => new SystemTextWebhookJsonParser(options)); return this; } - public WebhookReceiverBuilder UseJsonParser(Func> parser) - where TWebhook : class { - if (typeof(TWebhook) != WebhookType) - throw new ArgumentException($"The parser must return webhooks of type '{WebhookType}'"); - - var parserType = typeof(IWebhookJsonParser<>).MakeGenericType(WebhookType); - - Services.RemoveAll(parserType); - Services.AddSingleton(parserType, new DelegatedJsonParser(parser)); + /// + /// Registers a function as parser that is used to parse the JSON body of webhooks received + /// + /// + /// The function that is used to parse the JSON body of webhooks received + /// + /// + /// Returns the current builder instance with the parser registered + /// + /// + /// Thrown when the given is null + /// + public WebhookReceiverBuilder UseJsonParser(Func> parser) { + if (parser is null) + throw new ArgumentNullException(nameof(parser)); + + Services.AddSingleton>(_ => new DelegatedJsonParser(parser)); return this; } - public WebhookReceiverBuilder UseJsonParser(Func parser) - where TWebhook : class { - if (typeof(TWebhook) != WebhookType) - throw new ArgumentException($"The parser must return webhooks of type '{WebhookType}'"); - - var parserType = typeof(IWebhookJsonParser<>).MakeGenericType(WebhookType); - - Services.RemoveAll(parserType); - Services.AddSingleton(parserType, new DelegatedJsonParser(parser)); + /// + /// Registers a function as parser that is used to parse the JSON body of webhooks received + /// + /// + /// The function that is used to parse the JSON body of webhooks received + /// + /// + /// Returns the current builder instance with the parser registered + /// + /// + /// Thrown when the given is null + /// + public WebhookReceiverBuilder UseJsonParser(Func parser) { + if (parser is null) + throw new ArgumentNullException(nameof(parser)); + + Services.AddSingleton>(_ => new DelegatedJsonParser(parser)); return this; } - public WebhookReceiverBuilder UseSigner() where TSigner : class, IWebhookSigner { - var signerType = typeof(IWebhookSigner<>).MakeGenericType(WebhookType); - - if (!signerType.IsAssignableFrom(typeof(TSigner))) { - var signer = (IWebhookSigner) Activator.CreateInstance(typeof(TSigner)); - var wrapperType = typeof(WebhookSignerWrapper<>).MakeGenericType(WebhookType); - var wrapper = Activator.CreateInstance(wrapperType, new[] { signer }); - Services.AddSingleton(signerType, wrapper); + /// + /// Registers a service that is used to sign the payload of webhooks received + /// + /// + /// The type of the signer to use for the webhooks of type + /// + /// + /// Returns the current builder instance with the signer registered + /// + public WebhookReceiverBuilder UseSigner() where TSigner : class, IWebhookSigner { + if (!typeof(IWebhookSigner).IsAssignableFrom(typeof(TSigner))) { + Services.AddSingleton>(provider => { + var signer = provider.GetRequiredService(); + return new WebhookSignerWrapper(signer); + }); } else { - Services.AddSingleton(signerType, typeof(TSigner)); + Services.AddSingleton(provider => (IWebhookSigner) provider.GetRequiredService()); } - var providerType = typeof(IWebhookSignerProvider<>).MakeGenericType(WebhookType); - var defaultProviderType = typeof(DefaultWebhookSignerProvider<>).MakeGenericType(WebhookType); - Services.TryAddSingleton(providerType, defaultProviderType); + Services.TryAddSingleton(); + Services.TryAddSingleton, DefaultWebhookSignerProvider>(); return this; } - public WebhookReceiverBuilder UseSigner(TSigner provider) + /// + /// Registers a service that is used to sign the payload of webhooks received + /// + /// + /// The type of the signer to use for the webhooks of type + /// + /// + /// The instance of the signer to use for the webhooks of type + /// + /// + /// Returns the current builder instance with the signer registered + /// + /// + /// Thrown when the given is null + /// + public WebhookReceiverBuilder UseSigner(TSigner signer) where TSigner : class, IWebhookSigner { - var signerType = typeof(IWebhookSigner<>).MakeGenericType(WebhookType); + if (signer is null) + throw new ArgumentNullException(nameof(signer)); - if (!signerType.IsAssignableFrom(typeof(TSigner))) { - var wrapperType = typeof(WebhookSignerWrapper<>).MakeGenericType(WebhookType); - var wrapper = Activator.CreateInstance(wrapperType, new[] { provider }); - Services.AddSingleton(signerType, wrapper); + if (!typeof(IWebhookSigner).IsAssignableFrom(typeof(TSigner))) { + Services.AddSingleton>(_ => new WebhookSignerWrapper(signer)); } else { - Services.AddSingleton(signerType, typeof(TSigner)); + Services.AddSingleton(provider => (IWebhookSigner) provider.GetRequiredService()); } - var providerType = typeof(IWebhookSignerProvider<>).MakeGenericType(WebhookType); - var defaultProviderType = typeof(DefaultWebhookSignerProvider<>).MakeGenericType(WebhookType); - Services.TryAddSingleton(providerType, defaultProviderType); + Services.TryAddSingleton(signer); + Services.TryAddSingleton, DefaultWebhookSignerProvider>(); - return this; + return this; } - class DefaultWebhookSignerProvider : IWebhookSignerProvider - where TWebhook : class { + #region DefaultWebhookSignerProvider + + class DefaultWebhookSignerProvider : IWebhookSignerProvider { private readonly IDictionary signers; public DefaultWebhookSignerProvider(IEnumerable> signers) { @@ -240,7 +335,7 @@ public DefaultWebhookSignerProvider(IEnumerable> signer } } - public IWebhookSigner GetSigner(string algorithm) { + public IWebhookSigner? GetSigner(string algorithm) { if (!signers.TryGetValue(algorithm, out var signer)) return null; @@ -248,9 +343,11 @@ public IWebhookSigner GetSigner(string algorithm) { } } - #region WebhookSignatureProviderWrapper + #endregion + + #region WebhookSignatureProviderWrapper - class WebhookSignerWrapper : IWebhookSigner where TWebhook : class { + class WebhookSignerWrapper : IWebhookSigner { private readonly IWebhookSigner signer; public WebhookSignerWrapper(IWebhookSigner signer) { @@ -266,9 +363,9 @@ public WebhookSignerWrapper(IWebhookSigner signer) { #region DelegatedJsonParser - class DelegatedJsonParser : IWebhookJsonParser where TWebhook : class { - private readonly Func> streamParser; - private readonly Func syncStringParser; + class DelegatedJsonParser : IWebhookJsonParser { + private readonly Func>? streamParser; + private readonly Func? syncStringParser; public DelegatedJsonParser(Func syncStringParser) { this.syncStringParser = syncStringParser; @@ -278,7 +375,7 @@ public DelegatedJsonParser(Func> parse this.streamParser = parser; } - public async Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default) { + public async Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default) { if (streamParser != null) { return await streamParser(utf8Stream, cancellationToken); } else if (syncStringParser != null) { @@ -288,7 +385,7 @@ public async Task ParseWebhookAsync(Stream utf8Stream, CancellationTok return syncStringParser(json); } - throw new NotSupportedException(); + return null; } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverException.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverException.cs new file mode 100644 index 0000000..a39b8c0 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverException.cs @@ -0,0 +1,37 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace Deveel.Webhooks { + /// + /// An exception thrown when an error occurs during the processing of a webhook + /// + public class WebhookReceiverException : Exception { + /// + public WebhookReceiverException(string message, Exception innerException) + : base(message, innerException) { + } + + /// + public WebhookReceiverException(string message) + : base(message) { + } + + /// + public WebhookReceiverException() { + + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRceiverMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverMiddleware.cs similarity index 56% rename from src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRceiverMiddleware.cs rename to src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverMiddleware.cs index f6aba59..f829d0f 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRceiverMiddleware.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverMiddleware.cs @@ -1,17 +1,25 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. using Microsoft.AspNetCore.Http; namespace Deveel.Webhooks { - class WebhookRceiverMiddleware : IMiddleware where TWebhook : class { + class WebhookReceiverMiddleware : IMiddleware where TWebhook : class { private readonly IEnumerable> handlers; private readonly IWebhookReceiver receiver; - public WebhookRceiverMiddleware(IWebhookReceiver receiver, IEnumerable> handlers) { + public WebhookReceiverMiddleware(IWebhookReceiver receiver, IEnumerable> handlers) { this.receiver = receiver; this.handlers = handlers; } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs index 6883725..d5de849 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs @@ -1,11 +1,51 @@ -using System; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; namespace Deveel.Webhooks { - public class WebhookReceiverOptions { + /// + /// Provides the configuration options for a webhook receiver. + /// + public class WebhookReceiverOptions { + /// + /// Gets or sets whether the signature of the incoming webhook + /// should be verified. + /// public bool? VerifySignature { get; set; } + /// + /// Gets or sets the options for the signature verification. + /// public WebhookSignatureOptions Signature { get; set; } = new WebhookSignatureOptions(); + /// + /// Gets or sets the HTTP status code to return when the webhook + /// processing is successful (201 by default). + /// public int? ResponseStatusCode { get; set; } = 201; + + /// + /// Gets or sets the HTTP status code to return when the webhook + /// processing failed for an internal error (500 by default). + /// + public int? ErrorStatusCode { get; set; } = 500; + + /// + /// Gets or sets the HTTP status code to return when the webhook + /// from the sender is invalid (400 by default). + /// + public int? InvalidStatusCode { get; set; } = 400; } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs index 6f9f020..2b49e02 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs @@ -1,4 +1,18 @@ -using System; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureLocation.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureLocation.cs index 759d095..51f8bc9 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureLocation.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureLocation.cs @@ -1,4 +1,4 @@ -// Copyright 2022 Deveel +// Copyright 2022-2023 Deveel // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -15,8 +15,19 @@ using System; namespace Deveel.Webhooks { + /// + /// Enumerates the possible locations where the signature of a webhook + /// can be found within a HTTP request object. + /// public enum WebhookSignatureLocation { + /// + /// The signature is found in the HTTP header of the request. + /// Header, + + /// + /// The signature is found in the query string of the request. + /// QueryString } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureOptions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureOptions.cs index ff65bb0..c36fd55 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureOptions.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureOptions.cs @@ -1,13 +1,47 @@ -namespace Deveel.Webhooks { +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Deveel.Webhooks { + /// + /// Provides the configuration settings used to verify the signature + /// of a webhook sent to the receiver. + /// public class WebhookSignatureOptions { + /// + /// Gets or sets the location where the signature is found ( by default). + /// public WebhookSignatureLocation Location { get; set; } = WebhookSignatureLocation.Header; - public string ParameterName { get; set; } + /// + /// Gets or sets the name of the parameter that contains the signature. + /// + public string? ParameterName { get; set; } - public string Algorithm { get; set; } = "SHA-256"; + /// + /// Gets or sets the type of algorithm used to compute the signature. + /// + public string? Algorithm { get; set; } - public string Secret { get; set; } + /// + /// Gets or sets the secret used to compute the signature. + /// + public string? Secret { get; set; } + /// + /// Gets or sets the HTTP status code to return when the webhook + /// signature is invalid (400 by default). + /// public int? InvalidStatusCode { get; set; } = 400; } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationResult.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationResult.cs new file mode 100644 index 0000000..aa4e4b3 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationResult.cs @@ -0,0 +1,51 @@ +namespace Deveel.Webhooks { + /// + /// Represents the result of a verification of a webhook request. + /// + public readonly struct WebhookVerificationResult { + /// + /// Constructs the result of a verification of a webhook request. + /// + /// + /// Whether the request is verified or not + /// + /// + /// An optional response to be sent back to the sender of the webhook + /// + public WebhookVerificationResult(bool isVerified, object? challenge = null) { + IsVerified = isVerified; + Challenge = challenge; + } + + /// + /// Gets whether the request is verified or not. + /// + public bool IsVerified { get; } + + /// + /// Gets an optional response to be sent back to the sender of the webhook. + /// + public object? Challenge { get; } + + /// + /// Creates a new result of a successful verification of a webhook request + /// + /// + /// An optional response to be sent back to the sender of the webhook + /// + /// + /// Returns an instance of that + /// represents a successful verification of a webhook request. + /// + public static WebhookVerificationResult Verified(object? challenge = null) => new WebhookVerificationResult(true, challenge); + + /// + /// Creates a new result of a failed verification of a webhook request + /// + /// + /// Returns a new instance of that + /// represents a failed verification of a webhook request. + /// + public static WebhookVerificationResult Failed() => new WebhookVerificationResult(false); + } +} diff --git a/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.csproj b/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.csproj index c707a29..7ba3cba 100644 --- a/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.csproj +++ b/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.csproj @@ -33,7 +33,11 @@ - + + + + + diff --git a/src/Deveel.Webhooks/Deveel.Webhooks.csproj b/src/Deveel.Webhooks/Deveel.Webhooks.csproj index b50bcc3..b875f64 100644 --- a/src/Deveel.Webhooks/Deveel.Webhooks.csproj +++ b/src/Deveel.Webhooks/Deveel.Webhooks.csproj @@ -29,7 +29,11 @@ - + + + + + diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Deveel.Webhooks.Receiver.TestApi.csproj b/test/Deveel.Webhooks.Receiver.TestApi/Deveel.Webhooks.Receiver.TestApi.csproj index 746591a..8409144 100644 --- a/test/Deveel.Webhooks.Receiver.TestApi/Deveel.Webhooks.Receiver.TestApi.csproj +++ b/test/Deveel.Webhooks.Receiver.TestApi/Deveel.Webhooks.Receiver.TestApi.csproj @@ -8,6 +8,7 @@ + diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs index cf90af2..6669cdd 100644 --- a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs +++ b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs @@ -7,11 +7,11 @@ namespace Deveel.Webhooks.Handlers { public class TestWebhookHandler : IWebhookHandler { private readonly ILogger _logger; - private readonly WebhookReceiverOptions options; + private readonly WebhookReceiverOptions options; - public TestWebhookHandler(IOptions> options, ILogger logger) { + public TestWebhookHandler(IOptionsSnapshot options, ILogger logger) { _logger = logger; - this.options = options.Value; + this.options = options.Get(nameof(TestWebhook)); } public Task HandleAsync(TestWebhook webhook, CancellationToken cancellationToken = default) { diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Program.cs b/test/Deveel.Webhooks.Receiver.TestApi/Program.cs index 0030775..a68e4fc 100644 --- a/test/Deveel.Webhooks.Receiver.TestApi/Program.cs +++ b/test/Deveel.Webhooks.Receiver.TestApi/Program.cs @@ -20,7 +20,7 @@ public static void Main(string[] args) { var secret = builder.Configuration["Webhook:Receiver:Signature:Secret"]; builder.Services.AddWebhooks() - .ConfigureOptions>(options => { + .Configure(options => { options.VerifySignature = true; options.Signature.Secret = secret; options.Signature.ParameterName = "X-Webhook-Signature-256"; From 4648aa756bbfa3a7126a8ff868ee7c9c57cc9b21 Mon Sep 17 00:00:00 2001 From: Antonello Provenzano Date: Wed, 19 Apr 2023 13:22:44 +0200 Subject: [PATCH 4/8] Minor changes in the README text --- docs/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index 8e736fc..ebfee56 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,9 +16,12 @@ # Deveel Webhooks Documentation +Here you can find a documentation of the `Deveel Webhooks` framework, to help you getting started with the libraries and functions that compose it and to understand how it works. + + ## Basic Concepts -| Concept | Description | +| Topic | Description | | ---------------------------------------------------- | ------------------------------------------ | | **[Webhook](concept_webhook.md)** | What is it a 'Webhook' and why I need it? | | **[Subscriptions](concept_webhook_subscription.md)** | How does a subscription to an event works? | From 5ad2ba1ac90f30dd0ce18fe6f14f21f40048023e Mon Sep 17 00:00:00 2001 From: Antonello Provenzano Date: Wed, 19 Apr 2023 16:17:58 +0200 Subject: [PATCH 5/8] Changing the behavior of the middlewares to proceed with the pipeline --- ...Deveel.Webhooks.Receiver.AspNetCore.csproj | 1 + .../Deveel.Webhooks.Receiver.AspNetCore.xml | 10 +++ .../Webhooks/LoggerExtensions.cs | 11 +++ .../WebhookDelegatedReceiverMiddleware.cs | 72 ++++++++++++++----- .../Webhooks/WebhookReceiveResult.cs | 10 +++ .../Webhooks/WebhookReceiverMiddleware.cs | 58 +++++++++++---- .../Webhooks/WebhookReceiverOptions.cs | 10 ++- .../WebhookRequestVerfierMiddleware.cs | 42 ++++++++++- .../Webhooks/WebhookVerificationOptions.cs | 27 +++++++ .../Program.cs | 10 +-- .../Webhooks/WebhookReceiveRequestTests.cs | 11 ++- 11 files changed, 216 insertions(+), 46 deletions(-) create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationOptions.cs diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj index 5227a48..5477f5a 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml index 35dbfcd..3ff9a34 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml @@ -914,6 +914,16 @@ The webhook instance that was received + + + Gets whether the signature of the webhook was validated. + + + + + Gets whether the webhook was successfully received. + + An exception thrown when an error occurs during the processing of a webhook diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs new file mode 100644 index 0000000..0ee3f5e --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs @@ -0,0 +1,11 @@ +using System; + +using Microsoft.Extensions.Logging; + +namespace Deveel.Webhooks { + static partial class LoggerExtensions { + [LoggerMessage(EventId = -20222, Level = LogLevel.Warning, + Message = "It was not possible to resolve any webhook receiver for the type '{WebhookType}'")] + public static partial void WarnReceiverNotRegistered(this ILogger logger, Type webhookType); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs index e462a3a..a4343ea 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs @@ -14,6 +14,9 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; namespace Deveel.Webhooks { class WebhookDelegatedReceiverMiddleware where TWebhook : class { @@ -33,34 +36,65 @@ public WebhookDelegatedReceiverMiddleware(RequestDelegate next, syncHandler = handler; } + private WebhookReceiverOptions GetOptions(HttpContext context) { + var snapshot = context?.RequestServices?.GetService>(); + return snapshot?.GetReceiverOptions() ?? new WebhookReceiverOptions(); + } + + private ILogger GetLogger(HttpContext context) { + var loggerFactory = context?.RequestServices?.GetService(); + return loggerFactory?.CreateLogger>() ?? + NullLogger>.Instance; + } + public async Task InvokeAsync(HttpContext context) { - try { + var options = GetOptions(context); + var logger = GetLogger(context); + + try { var receiver = context.RequestServices.GetService>(); - if (receiver == null) { - await next(context); + + WebhookReceiveResult? result = null; + + if (receiver != null) { + result = await receiver.ReceiveAsync(context.Request, context.RequestAborted); + + if (result != null && result.Value.Successful && result.Value.Webhook != null) { + if (asyncHandler != null) { + await asyncHandler(context, result.Value.Webhook, context.RequestAborted); + } else if (syncHandler != null) { + syncHandler(context, result.Value.Webhook); + } + } } else { - var result = await receiver.ReceiveAsync(context.Request, context.RequestAborted); - - if (result.SignatureValid != null && !result.SignatureValid.Value) { - // TODO: get this from the configuration - context.Response.StatusCode = 400; - } else if (result.Webhook == null) { - context.Response.StatusCode = 400; - } else if (asyncHandler != null) { - await asyncHandler(context, result.Webhook, context.RequestAborted); - } else if (syncHandler != null) { - syncHandler(context, result.Webhook); - } else { - await next(context); + logger.WarnReceiverNotRegistered(typeof(TWebhook)); + } + + await next.Invoke(context); + + if (!context.Response.HasStarted && result != null) { + if ((result?.SignatureValidated ?? false) && !(result?.SignatureValid ?? false)) { + context.Response.StatusCode = options?.InvalidStatusCode ?? 400; + } else if ((result?.Successful ?? false)) { + context.Response.StatusCode = options?.ResponseStatusCode ?? 204; } + + // TODO: should we emit anything here? + await context.Response.WriteAsync(""); } - } catch (Exception ex) { + } catch (WebhookReceiverException ex) { // TODO: log this error ... - context.Response.StatusCode = 500; + if (!context.Response.HasStarted) { + context.Response.StatusCode = options?.ErrorStatusCode ?? 500; + await context.Response.WriteAsync(""); + } + // TODO: should we emit anything here? } + + } } -} +} \ No newline at end of file diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs index ca59353..1aad6eb 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs @@ -61,5 +61,15 @@ public WebhookReceiveResult(TWebhook? webhook, bool? signatureValid) : this() { /// public static implicit operator WebhookReceiveResult(TWebhook? webhook) => new WebhookReceiveResult(webhook, null); + + /// + /// Gets whether the signature of the webhook was validated. + /// + public bool SignatureValidated => SignatureValid.HasValue; + + /// + /// Gets whether the webhook was successfully received. + /// + public bool Successful => Webhook != null && (!SignatureValidated || SignatureValid == true); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverMiddleware.cs index f829d0f..5811b3a 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverMiddleware.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverMiddleware.cs @@ -13,39 +13,71 @@ // limitations under the License. using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; namespace Deveel.Webhooks { - class WebhookReceiverMiddleware : IMiddleware where TWebhook : class { - private readonly IEnumerable> handlers; + class WebhookReceiverMiddleware : IMiddleware where TWebhook : class { + private readonly IEnumerable>? handlers; private readonly IWebhookReceiver receiver; + private readonly WebhookReceiverOptions options; + private readonly ILogger logger; - public WebhookReceiverMiddleware(IWebhookReceiver receiver, IEnumerable> handlers) { + public WebhookReceiverMiddleware( + IOptionsSnapshot options, + IWebhookReceiver receiver, + IEnumerable>? handlers = null, + ILogger>? logger = null) { + this.options = options.GetReceiverOptions(); this.receiver = receiver; this.handlers = handlers; + this.logger = logger ?? NullLogger>.Instance; } + private int SuccessStatusCode => options.ResponseStatusCode ?? 200; + + private int FailureStatusCode => options.ErrorStatusCode ?? 500; + + private int InvalidStatusCode => options.InvalidStatusCode ?? 400; + public async Task InvokeAsync(HttpContext context, RequestDelegate next) { try { var result = await receiver.ReceiveAsync(context.Request, context.RequestAborted); - if (result.SignatureValid != null && !result.SignatureValid.Value) { - // TODO: get this from the configuration - context.Response.StatusCode = 400; - } else if (result.Webhook == null) { - context.Response.StatusCode = 400; - } else if (handlers != null) { + if (handlers != null && result.Successful && result.Webhook != null) { foreach (var handler in handlers) { await handler.HandleAsync(result.Webhook, context.RequestAborted); } - } else { - await next(context); } - } catch (Exception ex) { + + await next.Invoke(context); + + if (!context.Response.HasStarted) { + if (result.Successful) { + context.Response.StatusCode = SuccessStatusCode; + } else if (result.SignatureValidated && !result.SignatureValid.Value) { + context.Response.StatusCode = InvalidStatusCode; + } else { + context.Response.StatusCode = FailureStatusCode; + } + + // TODO: should we emit anything here? + await context.Response.WriteAsync(""); + } + } catch (WebhookReceiverException ex) { // TODO: log this error ... - context.Response.StatusCode = 500; + if (!context.Response.HasStarted) { + context.Response.StatusCode = FailureStatusCode; + + // TODO: should we emit anything here? + await context.Response.WriteAsync(""); + } + // TODO: should we emit anything here? } + } } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs index d5de849..e5207e0 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs @@ -28,13 +28,13 @@ public class WebhookReceiverOptions { /// /// Gets or sets the options for the signature verification. /// - public WebhookSignatureOptions Signature { get; set; } = new WebhookSignatureOptions(); + public WebhookSignatureOptions? Signature { get; set; } = new WebhookSignatureOptions(); /// /// Gets or sets the HTTP status code to return when the webhook /// processing is successful (201 by default). /// - public int? ResponseStatusCode { get; set; } = 201; + public int? ResponseStatusCode { get; set; } = 204; /// /// Gets or sets the HTTP status code to return when the webhook @@ -47,5 +47,11 @@ public class WebhookReceiverOptions { /// from the sender is invalid (400 by default). /// public int? InvalidStatusCode { get; set; } = 400; + + /// + /// Gets or sets the options for the verification of the webhook + /// requests by the sender. + /// + public WebhookVerificationOptions? Verification { get; set; } = new WebhookVerificationOptions(); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs index 2b49e02..6e83bec 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs @@ -16,9 +16,49 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; namespace Deveel.Webhooks { class WebhookRequestVerfierMiddleware : IMiddleware where TWebhook : class { - public Task InvokeAsync(HttpContext context, RequestDelegate next) => throw new NotImplementedException(); + private readonly WebhookReceiverOptions options; + private readonly IWebhookRequestVerifier? requestVerifier; + private readonly ILogger logger; + + public WebhookRequestVerfierMiddleware( + IOptionsSnapshot options, + IWebhookRequestVerifier? requestVerifier = null, + ILogger>? logger = null) { + this.options = options.GetReceiverOptions(); + this.requestVerifier = requestVerifier; + this.logger = logger ?? NullLogger>.Instance; + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) { + try { + WebhookVerificationResult? result = null; + + if (requestVerifier != null) { + result = await requestVerifier.VerifyRequestAsync(context.Request, context.RequestAborted); + } + + await next.Invoke(context); + + if (result != null && !context.Response.HasStarted) { + if (result?.IsVerified ?? false) { + + } else { + + } + } + } catch (WebhookReceiverException ex) { + if (!context.Response.HasStarted) { + + } + } + + + } } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationOptions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationOptions.cs new file mode 100644 index 0000000..b270e07 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationOptions.cs @@ -0,0 +1,27 @@ +using System; + +namespace Deveel.Webhooks { + /// + /// Provides a set of options to configure the behavior of the + /// verification process of a webhook request. + /// + public class WebhookVerificationOptions { + /// + /// Gets or sets the HTTP status code to return when the request + /// is authorized (200 by default). + /// + public int? SuccessStatusCode { get; set; } = 200; + + /// + /// Gets or sets the HTTP status code to return when the request + /// fails due to an internal error (500 by default). + /// + public int? FailureStatusCode { get; set; } = 500; + + /// + /// Gets or sets the HTTP status code to return when the request + /// is not authorized (403 by default). + /// + public int? NotAuthorizedStatusCode { get; set; } = 403; + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Program.cs b/test/Deveel.Webhooks.Receiver.TestApi/Program.cs index a68e4fc..8a059e5 100644 --- a/test/Deveel.Webhooks.Receiver.TestApi/Program.cs +++ b/test/Deveel.Webhooks.Receiver.TestApi/Program.cs @@ -11,7 +11,7 @@ public static void Main(string[] args) { builder.Services.AddLogging(); // Add services to the container. - builder.Services.AddAuthorization(); + // builder.Services.AddAuthorization(); builder.Services .AddWebhooks() .UseNewtonsoftJsonParser() @@ -32,11 +32,13 @@ public static void Main(string[] args) { var app = builder.Build(); - // Configure the HTTP request pipeline. + // app.UseDeveloperExceptionPage(); - app.UseHttpsRedirection(); + // Configure the HTTP request pipeline. - app.UseAuthorization(); + // app.UseHttpsRedirection(); + + // app.UseAuthorization(); app.UseWebhookReceiver("/webhook"); app.UseWebhookReceiver("/webhook/handled", (context, webhook) => { diff --git a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs index 14a72ca..c6d07b4 100644 --- a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs +++ b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs @@ -1,8 +1,6 @@ using System; using System.Net; using System.Net.Http; -using System.Net.Http.Json; -using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; @@ -16,13 +14,12 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; using Xunit; using Xunit.Abstractions; namespace Deveel.Webhooks { - public class WebhookReceiveRequestTests : IDisposable { + public class WebhookReceiveRequestTests : IDisposable { private readonly WebApplicationFactory appFactory; public WebhookReceiveRequestTests(ITestOutputHelper outputHelper) { @@ -47,7 +44,7 @@ public async Task ReceiveTestWebhook() { }); Assert.True(response.IsSuccessStatusCode); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } [Fact] @@ -63,7 +60,7 @@ public async Task ReceiveHandledTestWebhook() { }); Assert.True(response.IsSuccessStatusCode); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } @@ -97,7 +94,7 @@ public async Task ReceiveSignedTestWebhook() { var response = await client.SendAsync(request); Assert.True(response.IsSuccessStatusCode); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } [Fact] From 321a3479928c4e133720c6c11a281d16334f6927 Mon Sep 17 00:00:00 2001 From: Antonello Provenzano Date: Wed, 19 Apr 2023 18:42:24 +0200 Subject: [PATCH 6/8] Implementations of the verifier middleware and a default verification service --- .../Deveel.Webhooks.Receiver.AspNetCore.xml | 230 ++++++++++++++++-- .../Webhooks/IWebhookRequestVerifier.cs | 52 ++-- .../Webhooks/IWebhookVerificationResult.cs | 29 +++ .../Webhooks/LoggerExtensions.cs | 16 +- .../Webhooks/OptionsSnapshotExtensions.cs | 15 ++ .../Webhooks/WebhookReceiverBuilder.cs | 71 +++++- .../Webhooks/WebhookReceiverOptions.cs | 6 - .../WebhookRequestVerfierMiddleware.cs | 20 +- .../Webhooks/WebhookRequestVerifier.cs | 149 ++++++++++++ .../Webhooks/WebhookVerificationOptions.cs | 64 +++-- .../Webhooks/WebhookVerificationResult.cs | 39 +-- .../Program.cs | 15 +- .../appsettings.json | 1 + .../Webhooks/WebhookReceiveRequestTests.cs | 25 +- 14 files changed, 634 insertions(+), 98 deletions(-) create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookVerificationResult.cs create mode 100644 src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerifier.cs diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml index 3ff9a34..51c6f96 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml @@ -299,9 +299,19 @@ The type of webhook that is being verified + In several case scenarios, providers of webhooks require a verification of the party to ensure they are the ones who should be receiving the webhooks, and not a malicious party. + + + The process of verification is usually a two-step process, where the + request is first validated, and then the instance of the result of the + validation is passed back to the verifier to handle the result towards + the sender: this mechanism is used to ensure that the verification process + is specific for the provider of the webhook, since the different methodologies + implemented by various providers. + @@ -320,6 +330,19 @@ of the verification operation. + + + Handles the result of the verification of a webhook request. + + The result of the verification that should be handled + The HTTP response used to notify the sender + + A token that can be used to cancel the operation + + + Returns a that completes when the result is handled. + + Provides functions for the signing of a webhook payload @@ -384,6 +407,20 @@ + + + Represents the result of a verification of a webhook request. + + + This contract is used by implementations of + to proceed with a two-step verification of a webhook request. + + + + + Gets whether the request is verified or not. + + Extends the interface @@ -401,6 +438,20 @@ Returns the options for the receiver of the given type. + + + Gets the options for the webhook verifier of the given type. + + + The type of webhook handled by the verifier + + + The instance of the to extend + + + Returns the options for the verifier of the given type. + + A default implementation of the that handles @@ -737,6 +788,42 @@ Returns the current builder instance with the receiver registered + + + Registers an implementation of the + that is used to verify the webhooks verification requests from + senders + + + The type of the verifier to use for the webhooks of type + + + Returns the current builder instance with the verifier registered + + + + + Registers the default implementation of the + + + A delegate that can be used to configure the options for the verifier + + + Returns the current builder instance with the verifier registered + + + + + + Registers the default implementation of the + + + The path to the section in the configuration that contains the options + + + Return the current builder instance with the verifier registered + + Registers an handler for the webhooks of type @@ -851,6 +938,15 @@ Returns the current builder instance with the signer registered + + + Registers the default implementation of that is used + to sign the payload of webhooks received with a SHA256 hash + + + Returns the current builder instance with the signer registered + + Registers a service that is used to sign the payload of webhooks received @@ -972,6 +1068,90 @@ from the sender is invalid (400 by default). + + + A default implementation of a verifier of a webhook request that performs + a simple check for a token in the request matching one configured. + + + The type of webhook that is being verified + + + + + Constructs a instance with a + selector that resolves the options for the given type of webhook. + + + The provider of the options for the verification of the webhook request + + + + + Constructs a instance with the given options + + + + + + + Gets the options for the verification of the webhook request + + + + + Responds to the sender with a successful verification of the request. + + + The result of the verification of the request + + + The HTTP response object used to respond to the sender + + + A token that can be used to cancel the operation + + + Returns a that completes when the response is sent + + + + + Responds to the sender with a failed verification of the request. + + + The failed result of the verification of the request + + + The HTTP response object used to respond to the sender + + + A token that can be used to cancel the operation + + + Returns a that completes when the response is sent + + + + + + + + Tries to get the verification token from the given request. + + + The HTTP request object that carries the data used for the verification + + + A string that contains the token, if found in the request + + + Returns true if the token is found in the request, or false otherwise + + + + + Enumerates the possible locations where the signature of a webhook @@ -1020,45 +1200,65 @@ signature is invalid (400 by default). + + + Provides the configuration options for the default verification + of a webhook request. + + + + + Gets or sets a token that is matched against the value + sent by the provider to verify the identity of the receiver. + + + + + Gets or sets the name of the query parameter that contains + the verification token. + + + + + Gets or sets the HTTP status code to return when the request + is successfully verified (204 by default). + + + + + Gets or sets the HTTP status code to return when the request + is not authenticated (403 by default). + + - Represents the result of a verification of a webhook request. + Represents a default implementation of a result + of the verification of a webhook request. - + Constructs the result of a verification of a webhook request. Whether the request is verified or not - - An optional response to be sent back to the sender of the webhook - Gets whether the request is verified or not. - - - Gets an optional response to be sent back to the sender of the webhook. - - - + Creates a new result of a successful verification of a webhook request - - An optional response to be sent back to the sender of the webhook - Returns an instance of that represents a successful verification of a webhook request. - + Creates a new result of a failed verification of a webhook request diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs index 1dce865..c14f4f6 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs @@ -15,19 +15,29 @@ using Microsoft.AspNetCore.Http; namespace Deveel.Webhooks { - /// - /// A service that is used to verify a request of acknowledgement - /// by the sender of a webhook, before the webhook is sent. - /// - /// - /// The type of webhook that is being verified - /// - /// - /// In several case scenarios, providers of webhooks require a verification - /// of the party to ensure they are the ones who should be receiving the - /// webhooks, and not a malicious party. - /// - public interface IWebhookRequestVerifier { + /// + /// A service that is used to verify a request of acknowledgement + /// by the sender of a webhook, before the webhook is sent. + /// + /// + /// The type of webhook that is being verified + /// + /// + /// + /// In several case scenarios, providers of webhooks require a verification + /// of the party to ensure they are the ones who should be receiving the + /// webhooks, and not a malicious party. + /// + /// + /// The process of verification is usually a two-step process, where the + /// request is first validated, and then the instance of the result of the + /// validation is passed back to the verifier to handle the result towards + /// the sender: this mechanism is used to ensure that the verification process + /// is specific for the provider of the webhook, since the different methodologies + /// implemented by various providers. + /// + /// + public interface IWebhookRequestVerifier { /// /// Verifies the request of acknowledgement of a webhook. /// @@ -42,6 +52,20 @@ public interface IWebhookRequestVerifier { /// Returns a that indicates the result /// of the verification operation. /// - Task VerifyRequestAsync(HttpRequest httpRequest, CancellationToken cancellationToken = default); + Task VerifyRequestAsync(HttpRequest httpRequest, CancellationToken cancellationToken = default); + + + /// + /// Handles the result of the verification of a webhook request. + /// + /// The result of the verification that should be handled + /// The HTTP response used to notify the sender + /// + /// A token that can be used to cancel the operation + /// + /// + /// Returns a that completes when the result is handled. + /// + Task HandleResultAsync(IWebhookVerificationResult result, HttpResponse httpResponse, CancellationToken cancellationToken); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookVerificationResult.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookVerificationResult.cs new file mode 100644 index 0000000..a6426b8 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookVerificationResult.cs @@ -0,0 +1,29 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Deveel.Webhooks { + /// + /// Represents the result of a verification of a webhook request. + /// + /// + /// This contract is used by implementations of + /// to proceed with a two-step verification of a webhook request. + /// + public interface IWebhookVerificationResult { + /// + /// Gets whether the request is verified or not. + /// + public bool IsVerified { get; } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs index 0ee3f5e..cb6ce9b 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs @@ -1,4 +1,18 @@ -using System; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; using Microsoft.Extensions.Logging; diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/OptionsSnapshotExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/OptionsSnapshotExtensions.cs index f31062a..8366779 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/OptionsSnapshotExtensions.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/OptionsSnapshotExtensions.cs @@ -31,5 +31,20 @@ public static class OptionsSnapshotExtensions { /// public static WebhookReceiverOptions GetReceiverOptions(this IOptionsSnapshot options) => options.Get(typeof(TWebhook).Name); + + /// + /// Gets the options for the webhook verifier of the given type. + /// + /// + /// The type of webhook handled by the verifier + /// + /// + /// The instance of the to extend + /// + /// + /// Returns the options for the verifier of the given type. + /// + public static WebhookVerificationOptions GetVerificationOptions(this IOptionsSnapshot options) + => options.Get(typeof(TWebhook).Name); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs index 1e9a6b5..eeb21b6 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs @@ -50,7 +50,6 @@ public WebhookReceiverBuilder(IServiceCollection services) { Services.TryAddSingleton(this); RegisterReceiverMiddleware(); - RegisterVerifierMiddleware(); RegisterDefaultReceiver(); UseJsonParser(); @@ -103,6 +102,66 @@ public WebhookReceiverBuilder UseReceiver() return this; } + /// + /// Registers an implementation of the + /// that is used to verify the webhooks verification requests from + /// senders + /// + /// + /// The type of the verifier to use for the webhooks of type + /// + /// + /// Returns the current builder instance with the verifier registered + /// + public WebhookReceiverBuilder UseVerifier() + where TVerifier : class, IWebhookRequestVerifier { + RegisterVerifierMiddleware(); + + Services.AddScoped, TVerifier>(); + + if (!typeof(TVerifier).IsAbstract) + Services.AddScoped(typeof(TVerifier), typeof(TVerifier)); + + return this; + } + + /// + /// Registers the default implementation of the + /// + /// + /// A delegate that can be used to configure the options for the verifier + /// + /// + /// Returns the current builder instance with the verifier registered + /// + /// + public WebhookReceiverBuilder UseVerifier(Action configure) { + UseVerifier>(); + + Services.AddOptions(typeof(TWebhook).Name) + .Configure(configure); + + return this; + } + + /// + /// Registers the default implementation of the + /// + /// + /// The path to the section in the configuration that contains the options + /// + /// + /// Return the current builder instance with the verifier registered + /// + public WebhookReceiverBuilder UserVerifier(string sectionPath) { + UseVerifier>(); + + Services.AddOptions(typeof(TWebhook).Name) + .BindConfiguration(sectionPath); + + return this; + } + /// /// Registers an handler for the webhooks of type /// that were received. @@ -286,6 +345,16 @@ public WebhookReceiverBuilder UseSigner() where TSigner : cla return this; } + /// + /// Registers the default implementation of that is used + /// to sign the payload of webhooks received with a SHA256 hash + /// + /// + /// Returns the current builder instance with the signer registered + /// + public WebhookReceiverBuilder UseSha256Signer() + => UseSigner(); + /// /// Registers a service that is used to sign the payload of webhooks received /// diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs index e5207e0..7ef4eb4 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs @@ -47,11 +47,5 @@ public class WebhookReceiverOptions { /// from the sender is invalid (400 by default). /// public int? InvalidStatusCode { get; set; } = 400; - - /// - /// Gets or sets the options for the verification of the webhook - /// requests by the sender. - /// - public WebhookVerificationOptions? Verification { get; set; } = new WebhookVerificationOptions(); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs index 6e83bec..714cf88 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs @@ -23,12 +23,12 @@ namespace Deveel.Webhooks { class WebhookRequestVerfierMiddleware : IMiddleware where TWebhook : class { private readonly WebhookReceiverOptions options; - private readonly IWebhookRequestVerifier? requestVerifier; + private readonly IWebhookRequestVerifier requestVerifier; private readonly ILogger logger; public WebhookRequestVerfierMiddleware( IOptionsSnapshot options, - IWebhookRequestVerifier? requestVerifier = null, + IWebhookRequestVerifier requestVerifier, ILogger>? logger = null) { this.options = options.GetReceiverOptions(); this.requestVerifier = requestVerifier; @@ -37,28 +37,18 @@ public WebhookRequestVerfierMiddleware( public async Task InvokeAsync(HttpContext context, RequestDelegate next) { try { - WebhookVerificationResult? result = null; - - if (requestVerifier != null) { - result = await requestVerifier.VerifyRequestAsync(context.Request, context.RequestAborted); - } + var result = await requestVerifier.VerifyRequestAsync(context.Request, context.RequestAborted); await next.Invoke(context); - if (result != null && !context.Response.HasStarted) { - if (result?.IsVerified ?? false) { - - } else { - - } + if (!context.Response.HasStarted) { + await requestVerifier.HandleResultAsync(result, context.Response, context.RequestAborted); } } catch (WebhookReceiverException ex) { if (!context.Response.HasStarted) { } } - - } } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerifier.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerifier.cs new file mode 100644 index 0000000..c11685b --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerifier.cs @@ -0,0 +1,149 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Diagnostics.CodeAnalysis; + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Deveel.Webhooks { + /// + /// A default implementation of a verifier of a webhook request that performs + /// a simple check for a token in the request matching one configured. + /// + /// + /// The type of webhook that is being verified + /// + public class WebhookRequestVerifier : IWebhookRequestVerifier + where TWebhook : class { + /// + /// Constructs a instance with a + /// selector that resolves the options for the given type of webhook. + /// + /// + /// The provider of the options for the verification of the webhook request + /// + public WebhookRequestVerifier(IOptionsSnapshot options) + : this(options.GetVerificationOptions()) { + } + + /// + /// Constructs a instance with the given options + /// + /// + /// + protected WebhookRequestVerifier(WebhookVerificationOptions options) { + VerificationOptions = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Gets the options for the verification of the webhook request + /// + protected WebhookVerificationOptions VerificationOptions { get; } + + /// + /// Responds to the sender with a successful verification of the request. + /// + /// + /// The result of the verification of the request + /// + /// + /// The HTTP response object used to respond to the sender + /// + /// + /// A token that can be used to cancel the operation + /// + /// + /// Returns a that completes when the response is sent + /// + protected async Task OnSuccessAsync(IWebhookVerificationResult result, HttpResponse httpResponse, CancellationToken cancellationToken) { + httpResponse.StatusCode = VerificationOptions.SuccessStatusCode ?? 200; + + // TODO: Should we emit anything here? + await httpResponse.WriteAsync(""); + } + + /// + /// Responds to the sender with a failed verification of the request. + /// + /// + /// The failed result of the verification of the request + /// + /// + /// The HTTP response object used to respond to the sender + /// + /// + /// A token that can be used to cancel the operation + /// + /// + /// Returns a that completes when the response is sent + /// + protected async Task OnNotAuthenticatedAsync(IWebhookVerificationResult result, HttpResponse httpResponse, CancellationToken cancellationToken) { + httpResponse.StatusCode = VerificationOptions.NotAuthenticatedStatusCode ?? 403; + + // TODO: Should we emit anything here? + await httpResponse.WriteAsync(""); + } + + /// + public virtual async Task HandleResultAsync(IWebhookVerificationResult result, HttpResponse httpResponse, CancellationToken cancellationToken) { + if (result.IsVerified) { + await OnSuccessAsync(result, httpResponse, cancellationToken); + } else { + await OnNotAuthenticatedAsync(result, httpResponse, cancellationToken); + } + } + + /// + /// Tries to get the verification token from the given request. + /// + /// + /// The HTTP request object that carries the data used for the verification + /// + /// + /// A string that contains the token, if found in the request + /// + /// + /// Returns true if the token is found in the request, or false otherwise + /// + protected virtual bool TryGetVerificationToken(HttpRequest request, [MaybeNullWhen(false)] out string? token) { + var verificationTokenQueryName = VerificationOptions.VerificationTokenQueryName; + + if (String.IsNullOrWhiteSpace(verificationTokenQueryName)) { + token = null; + return false; + } + + if (!request.Query.TryGetValue(verificationTokenQueryName, out var value)) { + token = null; + return false; + } + + token = value; + return true; + } + + /// + public virtual Task VerifyRequestAsync(HttpRequest httpRequest, CancellationToken cancellationToken = default) { + var verificationToken = VerificationOptions.VerificationToken; + + if (!TryGetVerificationToken(httpRequest, out var token) || + String.IsNullOrWhiteSpace(verificationToken) || + !String.Equals(token, verificationToken, StringComparison.Ordinal)) + return Task.FromResult(WebhookVerificationResult.Failed); + + return Task.FromResult(WebhookVerificationResult.Verified); + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationOptions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationOptions.cs index b270e07..a6dc764 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationOptions.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationOptions.cs @@ -1,27 +1,47 @@ -using System; +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; namespace Deveel.Webhooks { - /// - /// Provides a set of options to configure the behavior of the - /// verification process of a webhook request. - /// - public class WebhookVerificationOptions { - /// - /// Gets or sets the HTTP status code to return when the request - /// is authorized (200 by default). - /// - public int? SuccessStatusCode { get; set; } = 200; + /// + /// Provides the configuration options for the default verification + /// of a webhook request. + /// + public class WebhookVerificationOptions { + /// + /// Gets or sets a token that is matched against the value + /// sent by the provider to verify the identity of the receiver. + /// + public string? VerificationToken { get; set; } + + /// + /// Gets or sets the name of the query parameter that contains + /// the verification token. + /// + public string? VerificationTokenQueryName { get; set; } - /// - /// Gets or sets the HTTP status code to return when the request - /// fails due to an internal error (500 by default). - /// - public int? FailureStatusCode { get; set; } = 500; + /// + /// Gets or sets the HTTP status code to return when the request + /// is successfully verified (204 by default). + /// + public int? SuccessStatusCode { get; set; } = 204; - /// - /// Gets or sets the HTTP status code to return when the request - /// is not authorized (403 by default). - /// - public int? NotAuthorizedStatusCode { get; set; } = 403; - } + /// + /// Gets or sets the HTTP status code to return when the request + /// is not authenticated (403 by default). + /// + public int? NotAuthenticatedStatusCode { get; set; } = 403; + } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationResult.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationResult.cs index aa4e4b3..f943357 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationResult.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationResult.cs @@ -1,20 +1,31 @@ -namespace Deveel.Webhooks { +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Deveel.Webhooks { /// - /// Represents the result of a verification of a webhook request. + /// Represents a default implementation of a result + /// of the verification of a webhook request. /// - public readonly struct WebhookVerificationResult { + public readonly struct WebhookVerificationResult : IWebhookVerificationResult { /// /// Constructs the result of a verification of a webhook request. /// /// /// Whether the request is verified or not /// - /// - /// An optional response to be sent back to the sender of the webhook - /// - public WebhookVerificationResult(bool isVerified, object? challenge = null) { + private WebhookVerificationResult(bool isVerified) { IsVerified = isVerified; - Challenge = challenge; } /// @@ -22,22 +33,14 @@ public WebhookVerificationResult(bool isVerified, object? challenge = null) { /// public bool IsVerified { get; } - /// - /// Gets an optional response to be sent back to the sender of the webhook. - /// - public object? Challenge { get; } - /// /// Creates a new result of a successful verification of a webhook request /// - /// - /// An optional response to be sent back to the sender of the webhook - /// /// /// Returns an instance of that /// represents a successful verification of a webhook request. /// - public static WebhookVerificationResult Verified(object? challenge = null) => new WebhookVerificationResult(true, challenge); + public static WebhookVerificationResult Verified { get; } = new WebhookVerificationResult(true); /// /// Creates a new result of a failed verification of a webhook request @@ -46,6 +49,6 @@ public WebhookVerificationResult(bool isVerified, object? challenge = null) { /// Returns a new instance of that /// represents a failed verification of a webhook request. /// - public static WebhookVerificationResult Failed() => new WebhookVerificationResult(false); + public static WebhookVerificationResult Failed { get; } = new WebhookVerificationResult(false); } } diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Program.cs b/test/Deveel.Webhooks.Receiver.TestApi/Program.cs index 8a059e5..7c5699f 100644 --- a/test/Deveel.Webhooks.Receiver.TestApi/Program.cs +++ b/test/Deveel.Webhooks.Receiver.TestApi/Program.cs @@ -11,7 +11,7 @@ public static void Main(string[] args) { builder.Services.AddLogging(); // Add services to the container. - // builder.Services.AddAuthorization(); + builder.Services.AddAuthorization(); builder.Services .AddWebhooks() .UseNewtonsoftJsonParser() @@ -26,19 +26,23 @@ public static void Main(string[] args) { options.Signature.ParameterName = "X-Webhook-Signature-256"; options.Signature.Location = WebhookSignatureLocation.Header; }) + .UseVerifier(options => { + options.VerificationToken = builder.Configuration["Webhook:Receiver:VerificationToken"]; + options.VerificationTokenQueryName = "token"; + }) .UseNewtonsoftJsonParser() - .UseSigner() + .UseSha256Signer() .AddHandler(); var app = builder.Build(); - // app.UseDeveloperExceptionPage(); + app.UseDeveloperExceptionPage(); // Configure the HTTP request pipeline. - // app.UseHttpsRedirection(); + app.UseHttpsRedirection(); - // app.UseAuthorization(); + app.UseAuthorization(); app.UseWebhookReceiver("/webhook"); app.UseWebhookReceiver("/webhook/handled", (context, webhook) => { @@ -47,6 +51,7 @@ public static void Main(string[] args) { logger.LogInformation(JsonConvert.SerializeObject(webhook)); }); + app.UseWebhookVerfier("/webhook/signed"); app.UseWebhookReceiver("/webhook/signed"); app.Run(); diff --git a/test/Deveel.Webhooks.Receiver.TestApi/appsettings.json b/test/Deveel.Webhooks.Receiver.TestApi/appsettings.json index 1d1a878..db83a9b 100644 --- a/test/Deveel.Webhooks.Receiver.TestApi/appsettings.json +++ b/test/Deveel.Webhooks.Receiver.TestApi/appsettings.json @@ -8,6 +8,7 @@ "AllowedHosts": "*", "Webhook": { "Receiver": { + "VerificationToken": "NAP2fDWEDzdw5gXtESPyjSp", "Signature": { "Secret": "qjs62wtg155s7dd7exdgdj" } diff --git a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs index c6d07b4..856c958 100644 --- a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs +++ b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs @@ -1,4 +1,5 @@ using System; +using System.Formats.Asn1; using System.Net; using System.Net.Http; using System.Text; @@ -19,7 +20,7 @@ using Xunit.Abstractions; namespace Deveel.Webhooks { - public class WebhookReceiveRequestTests : IDisposable { + public class WebhookReceiveRequestTests : IDisposable { private readonly WebApplicationFactory appFactory; public WebhookReceiveRequestTests(ITestOutputHelper outputHelper) { @@ -140,5 +141,27 @@ public async Task ReceiveSignedTestWebhook_NoSignature() { Assert.False(response.IsSuccessStatusCode); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + + [Fact] + public async Task VerifyReceiver() { + var client = CreateClient(); + + var token = appFactory.Services.GetRequiredService()["Webhook:Receiver:VerificationToken"]; + + var response = await client.GetAsync($"/webhook/signed?token={token}"); + + Assert.True(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task VerifyReceiver_InvalidToken() { + var client = CreateClient(); + + var response = await client.GetAsync($"/webhook/signed?token={Guid.NewGuid().ToString("N")}"); + + Assert.False (response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } } } From c04418bc985bb8a4e8e8f98c2bece77587cbc7f3 Mon Sep 17 00:00:00 2001 From: Antonello Provenzano Date: Wed, 19 Apr 2023 20:00:17 +0200 Subject: [PATCH 7/8] Logging middleware events and testing with callbacks --- .../Deveel.Webhooks.Receiver.AspNetCore.xml | 5 +++ .../Webhooks/LoggerExtensions.cs | 40 ++++++++++++++++++- .../WebhookDelegatedReceiverMiddleware.cs | 37 ++++++++++++----- .../Webhooks/WebhookReceiveResult.cs | 5 +++ .../Webhooks/WebhookReceiverBuilder.cs | 12 +++--- .../Webhooks/WebhookReceiverMiddleware.cs | 20 +++++++--- .../WebhookRequestVerfierMiddleware.cs | 21 +++++++++- .../Handlers/IWebhookCallback.cs | 5 +++ .../Handlers/TestSignedWebhookHandler.cs | 8 ++-- .../Handlers/TestWebhookHandler.cs | 13 +++--- .../WebhookReceiverBuilderExtensions.cs | 25 ++++++++++++ .../Program.cs | 11 +++-- .../appsettings.Development.json | 4 +- .../Webhooks/WebhookReceiveRequestTests.cs | 39 +++++++++++++++++- 14 files changed, 199 insertions(+), 46 deletions(-) create mode 100644 test/Deveel.Webhooks.Receiver.TestApi/Handlers/IWebhookCallback.cs create mode 100644 test/Deveel.Webhooks.Receiver.TestApi/Handlers/WebhookReceiverBuilderExtensions.cs diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml index 51c6f96..26d39f8 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml @@ -1020,6 +1020,11 @@ Gets whether the webhook was successfully received. + + + Gets whether the webhook was received but the signature was invalid. + + An exception thrown when an error occurs during the processing of a webhook diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs index cb6ce9b..b9486e7 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs @@ -18,8 +18,44 @@ namespace Deveel.Webhooks { static partial class LoggerExtensions { + [LoggerMessage(EventId = 10001, Level = LogLevel.Debug, + Message = "A Webhook has arrived")] + public static partial void TraceWebhookArrived(this ILogger logger); + + [LoggerMessage(EventId = 10002, Level = LogLevel.Debug, + Message = "A Webhook has been received")] + public static partial void TraceWebhookReceived(this ILogger logger); + + [LoggerMessage(EventId = 10003, Level = LogLevel.Debug, + Message = "The webhook of has been handled by '{HandlerType}'")] + public static partial void TraceWebhookHandled(this ILogger logger, Type handlerType); + + [LoggerMessage(EventId = 10004, Level = LogLevel.Debug, + Message = "A request of verification has arrived")] + public static partial void TraceVerificationRequest(this ILogger logger); + + [LoggerMessage(EventId = 10005, Level = LogLevel.Debug, + Message = "The verification request has been completed successfully")] + public static partial void TraceSuccessVerification(this ILogger logger); + + [LoggerMessage(EventId = -20226, Level = LogLevel.Warning, + Message = "The verification request has failed")] + public static partial void WarnVerificationFailed(this ILogger logger); + [LoggerMessage(EventId = -20222, Level = LogLevel.Warning, - Message = "It was not possible to resolve any webhook receiver for the type '{WebhookType}'")] - public static partial void WarnReceiverNotRegistered(this ILogger logger, Type webhookType); + Message = "It was not possible to resolve any webhook receiver")] + public static partial void WarnReceiverNotRegistered(this ILogger logger); + + [LoggerMessage(EventId = -20225, Level = LogLevel.Warning, + Message = "It was not possible to verify the signature of the webhook")] + public static partial void WarnInvalidSignature(this ILogger logger); + + [LoggerMessage(EventId = -20224, Level = LogLevel.Warning, + Message = "The received webhook is invalid")] + public static partial void WarnInvalidWebhook(this ILogger logger); + + [LoggerMessage(EventId = -20223, Level = LogLevel.Error, + Message = "It was not possible to receive a webhook for an unhandled error")] + public static partial void LogUnhandledReceiveError(this ILogger logger, Exception error); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs index a4343ea..57b26f6 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs @@ -53,6 +53,8 @@ public async Task InvokeAsync(HttpContext context) { var logger = GetLogger(context); try { + logger.TraceWebhookArrived(); + var receiver = context.RequestServices.GetService>(); WebhookReceiveResult? result = null; @@ -60,15 +62,31 @@ public async Task InvokeAsync(HttpContext context) { if (receiver != null) { result = await receiver.ReceiveAsync(context.Request, context.RequestAborted); - if (result != null && result.Value.Successful && result.Value.Webhook != null) { - if (asyncHandler != null) { - await asyncHandler(context, result.Value.Webhook, context.RequestAborted); - } else if (syncHandler != null) { - syncHandler(context, result.Value.Webhook); + if (result?.Successful ?? false) { + var webhook = result?.Webhook; + + if (webhook == null) { + logger.WarnInvalidWebhook(); + } else { + logger.TraceWebhookReceived(); + + if (asyncHandler != null) { + await asyncHandler(context, webhook, context.RequestAborted); + + logger.TraceWebhookHandled(typeof(Func)); + } else if (syncHandler != null) { + syncHandler(context, webhook); + + logger.TraceWebhookHandled(typeof(Action)); + } } + } else { + logger.WarnInvalidWebhook(); } + } else if (result?.SignatureFailed ?? false) { + logger.WarnInvalidSignature(); } else { - logger.WarnReceiverNotRegistered(typeof(TWebhook)); + logger.WarnReceiverNotRegistered(); } await next.Invoke(context); @@ -84,17 +102,14 @@ public async Task InvokeAsync(HttpContext context) { await context.Response.WriteAsync(""); } } catch (WebhookReceiverException ex) { - // TODO: log this error ... + logger.LogUnhandledReceiveError(ex); if (!context.Response.HasStarted) { context.Response.StatusCode = options?.ErrorStatusCode ?? 500; + // TODO: should we emit anything here? await context.Response.WriteAsync(""); } - - // TODO: should we emit anything here? } - - } } } \ No newline at end of file diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs index 1aad6eb..e7fd35d 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs @@ -71,5 +71,10 @@ public static implicit operator WebhookReceiveResult(TWebhook? webhook /// Gets whether the webhook was successfully received. /// public bool Successful => Webhook != null && (!SignatureValidated || SignatureValid == true); + + /// + /// Gets whether the webhook was received but the signature was invalid. + /// + public bool SignatureFailed => SignatureValidated && SignatureValid == false; } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs index eeb21b6..fc8b9b2 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs @@ -69,11 +69,11 @@ public WebhookReceiverBuilder() public IServiceCollection Services { get; } private void RegisterReceiverMiddleware() { - Services.AddScoped>(); + Services.TryAddScoped>(); } private void RegisterVerifierMiddleware() { - Services.AddScoped>(); + Services.TryAddScoped>(); } private void RegisterDefaultReceiver() { @@ -272,8 +272,8 @@ public WebhookReceiverBuilder UseJsonParser(TParser parser) /// Returns the current builder instance with the parser registered /// public WebhookReceiverBuilder UseJsonParser(JsonSerializerOptions? options = null) { - Services.AddSingleton>(_ => new SystemTextWebhookJsonParser(options)); - Services.AddSingleton(_ => new SystemTextWebhookJsonParser(options)); + Services.TryAddSingleton>(_ => new SystemTextWebhookJsonParser(options)); + Services.TryAddSingleton(_ => new SystemTextWebhookJsonParser(options)); return this; } @@ -294,7 +294,7 @@ public WebhookReceiverBuilder UseJsonParser(Func>(_ => new DelegatedJsonParser(parser)); + Services.TryAddSingleton>(_ => new DelegatedJsonParser(parser)); return this; } @@ -315,7 +315,7 @@ public WebhookReceiverBuilder UseJsonParser(Func par if (parser is null) throw new ArgumentNullException(nameof(parser)); - Services.AddSingleton>(_ => new DelegatedJsonParser(parser)); + Services.TryAddSingleton>(_ => new DelegatedJsonParser(parser)); return this; } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverMiddleware.cs index 5811b3a..2fb11aa 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverMiddleware.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverMiddleware.cs @@ -43,11 +43,23 @@ public WebhookReceiverMiddleware( public async Task InvokeAsync(HttpContext context, RequestDelegate next) { try { + logger.TraceWebhookArrived(); + var result = await receiver.ReceiveAsync(context.Request, context.RequestAborted); - + + if (result.Successful) { + logger.TraceWebhookReceived(); + } else if (result.SignatureFailed) { + logger.WarnInvalidSignature(); + } else if (!result.Successful) { + logger.WarnInvalidWebhook(); + } + if (handlers != null && result.Successful && result.Webhook != null) { foreach (var handler in handlers) { await handler.HandleAsync(result.Webhook, context.RequestAborted); + + logger.TraceWebhookHandled(handler.GetType()); } } @@ -56,7 +68,7 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) { if (!context.Response.HasStarted) { if (result.Successful) { context.Response.StatusCode = SuccessStatusCode; - } else if (result.SignatureValidated && !result.SignatureValid.Value) { + } else if (result.SignatureFailed) { context.Response.StatusCode = InvalidStatusCode; } else { context.Response.StatusCode = FailureStatusCode; @@ -66,7 +78,7 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) { await context.Response.WriteAsync(""); } } catch (WebhookReceiverException ex) { - // TODO: log this error ... + logger.LogUnhandledReceiveError(ex); if (!context.Response.HasStarted) { context.Response.StatusCode = FailureStatusCode; @@ -74,8 +86,6 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) { // TODO: should we emit anything here? await context.Response.WriteAsync(""); } - - // TODO: should we emit anything here? } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs index 714cf88..a30010a 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs @@ -35,18 +35,35 @@ public WebhookRequestVerfierMiddleware( this.logger = logger ?? NullLogger>.Instance; } + private int FailureStatusCode => options.ErrorStatusCode ?? 500; + public async Task InvokeAsync(HttpContext context, RequestDelegate next) { try { + logger.TraceVerificationRequest(); + var result = await requestVerifier.VerifyRequestAsync(context.Request, context.RequestAborted); + if (result != null) { + if (result.IsVerified) { + logger.TraceSuccessVerification(); + } else { + logger.WarnVerificationFailed(); + } + } + await next.Invoke(context); - if (!context.Response.HasStarted) { + if (!context.Response.HasStarted && result != null) { await requestVerifier.HandleResultAsync(result, context.Response, context.RequestAborted); } } catch (WebhookReceiverException ex) { + logger.LogUnhandledReceiveError(ex); + if (!context.Response.HasStarted) { - + context.Response.StatusCode = FailureStatusCode; + + // TODO: Should we emit anything here? + await context.Response.WriteAsync(""); } } } diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/IWebhookCallback.cs b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/IWebhookCallback.cs new file mode 100644 index 0000000..ac40665 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/IWebhookCallback.cs @@ -0,0 +1,5 @@ +namespace Deveel.Webhooks.Handlers { + public interface IWebhookCallback { + void OnWebhookHandled(TWebhook? webhook); + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestSignedWebhookHandler.cs b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestSignedWebhookHandler.cs index a5de01a..dc02af8 100644 --- a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestSignedWebhookHandler.cs +++ b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestSignedWebhookHandler.cs @@ -4,14 +4,14 @@ namespace Deveel.Webhooks.Handlers { public class TestSignedWebhookHandler : IWebhookHandler { - private readonly ILogger _logger; + private readonly IWebhookCallback _callback; - public TestSignedWebhookHandler(ILogger logger) { - _logger = logger; + public TestSignedWebhookHandler(IWebhookCallback callback) { + _callback = callback; } public Task HandleAsync(TestSignedWebhook webhook, CancellationToken cancellationToken = default) { - _logger.LogInformation(JsonConvert.SerializeObject(webhook)); + _callback.OnWebhookHandled(webhook); return Task.CompletedTask; } diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs index 6669cdd..bb03a7b 100644 --- a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs +++ b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs @@ -2,21 +2,18 @@ using Microsoft.Extensions.Options; -using Newtonsoft.Json; - namespace Deveel.Webhooks.Handlers { public class TestWebhookHandler : IWebhookHandler { - private readonly ILogger _logger; private readonly WebhookReceiverOptions options; + private readonly IWebhookCallback callback; - public TestWebhookHandler(IOptionsSnapshot options, ILogger logger) { - _logger = logger; - this.options = options.Get(nameof(TestWebhook)); + public TestWebhookHandler(IOptionsSnapshot options, IWebhookCallback callback) { + this.options = options.GetReceiverOptions(); + this.callback = callback; } public Task HandleAsync(TestWebhook webhook, CancellationToken cancellationToken = default) { - _logger.LogInformation(JsonConvert.SerializeObject(webhook)); - + callback.OnWebhookHandled(webhook); return Task.CompletedTask; } } diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/WebhookReceiverBuilderExtensions.cs b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/WebhookReceiverBuilderExtensions.cs new file mode 100644 index 0000000..05c35ce --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/WebhookReceiverBuilderExtensions.cs @@ -0,0 +1,25 @@ +namespace Deveel.Webhooks.Handlers { + public static class WebhookReceiverBuilderExtensions { + public static WebhookReceiverBuilder UseCallback(this WebhookReceiverBuilder builder, IWebhookCallback callback) + where TWebhook : class { + builder.Services.AddSingleton(callback); + return builder; + } + + public static WebhookReceiverBuilder UseCallback(this WebhookReceiverBuilder builder, Action callback) + where TWebhook : class { + builder.Services.AddSingleton>(new DelegatedWebhookCallback(callback)); + return builder; + } + } + + class DelegatedWebhookCallback : IWebhookCallback where TWebhook : class { + private readonly Action callback; + + public DelegatedWebhookCallback(Action callback) { + this.callback = callback; + } + + public void OnWebhookHandled(TWebhook? webhook) => callback(webhook); + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Program.cs b/test/Deveel.Webhooks.Receiver.TestApi/Program.cs index 7c5699f..716cea0 100644 --- a/test/Deveel.Webhooks.Receiver.TestApi/Program.cs +++ b/test/Deveel.Webhooks.Receiver.TestApi/Program.cs @@ -1,8 +1,6 @@ using Deveel.Webhooks.Handlers; using Deveel.Webhooks.Model; -using Newtonsoft.Json; - namespace Deveel.Webhooks.Receiver.TestApi { public class Program { public static void Main(string[] args) { @@ -46,9 +44,14 @@ public static void Main(string[] args) { app.UseWebhookReceiver("/webhook"); app.UseWebhookReceiver("/webhook/handled", (context, webhook) => { - var logger = context.RequestServices.GetRequiredService().CreateLogger("test"); + var callback = context.RequestServices.GetRequiredService>(); + + callback.OnWebhookHandled(webhook); + }); - logger.LogInformation(JsonConvert.SerializeObject(webhook)); + app.UseWebhookReceiver("/webhook/handled/async", async (context, webhook, token) => { + var callback = context.RequestServices.GetRequiredService>(); + callback.OnWebhookHandled(webhook); }); app.UseWebhookVerfier("/webhook/signed"); diff --git a/test/Deveel.Webhooks.Receiver.TestApi/appsettings.Development.json b/test/Deveel.Webhooks.Receiver.TestApi/appsettings.Development.json index 0c208ae..33e2405 100644 --- a/test/Deveel.Webhooks.Receiver.TestApi/appsettings.Development.json +++ b/test/Deveel.Webhooks.Receiver.TestApi/appsettings.Development.json @@ -1,8 +1,8 @@ { "Logging": { "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Default": "Trace", + "Microsoft.AspNetCore": "Trace" } } } diff --git a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs index 856c958..1a494e8 100644 --- a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs +++ b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs @@ -1,15 +1,16 @@ using System; -using System.Formats.Asn1; using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; +using Deveel.Webhooks.Handlers; using Deveel.Webhooks.Model; using Deveel.Webhooks.Receiver.TestApi; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -22,10 +23,22 @@ namespace Deveel.Webhooks { public class WebhookReceiveRequestTests : IDisposable { private readonly WebApplicationFactory appFactory; + private TestWebhook lastWebhook; public WebhookReceiveRequestTests(ITestOutputHelper outputHelper) { appFactory = new WebApplicationFactory() - .WithWebHostBuilder(builder => builder.ConfigureLogging(logging => logging.AddXUnit(outputHelper).SetMinimumLevel(LogLevel.Trace))); + .WithWebHostBuilder(builder => builder + .ConfigureTestServices(ConfigureServices) + .ConfigureLogging(logging => logging.AddXUnit(outputHelper, opt => opt.Filter = (cat, level) => true) + .SetMinimumLevel(LogLevel.Trace))); + } + + private void ConfigureServices(IServiceCollection services) { + services.AddWebhooks() + .UseCallback(webhook => lastWebhook = webhook); + + services.AddWebhooks() + .UseCallback(webhook => lastWebhook = webhook); } public void Dispose() => appFactory?.Dispose(); @@ -46,6 +59,9 @@ public async Task ReceiveTestWebhook() { Assert.True(response.IsSuccessStatusCode); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + Assert.NotNull(lastWebhook); + Assert.Equal("test", lastWebhook.Event); } [Fact] @@ -62,9 +78,28 @@ public async Task ReceiveHandledTestWebhook() { Assert.True(response.IsSuccessStatusCode); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + Assert.NotNull(lastWebhook); + } + + [Fact] + public async Task ReceiveAsyncHandledTestWebhook() { + var client = CreateClient(); + + var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/webhook/handled/async") { + Content = new StringContent(JsonConvert.SerializeObject(new TestWebhook { + Id = Guid.NewGuid().ToString("N"), + Event = "test", + TimeStamp = DateTimeOffset.Now, + }), Encoding.UTF8, "application/json") + }); + + Assert.True(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } + private string GetSha256Signature(string json) { var config = appFactory.Services.GetRequiredService(); From 1a9f733b6350f4b68d6188c28a6d99017e77f506 Mon Sep 17 00:00:00 2001 From: Antonello Provenzano Date: Wed, 19 Apr 2023 20:03:50 +0200 Subject: [PATCH 8/8] Incrementing the version of the framework's packages --- .../Deveel.Webhooks.DynamicLinq.csproj | 6 +++--- src/Deveel.Webhooks.Model/Deveel.Webhooks.Model.csproj | 6 +++--- ...eveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson.csproj | 6 +++--- .../Deveel.Webhooks.Receiver.AspNetCore.csproj | 6 +++--- .../Deveel.Webhooks.MongoDb.csproj | 6 +++--- src/Deveel.Webhooks.Service/Deveel.Webhooks.Service.csproj | 6 +++--- src/Deveel.Webhooks/Deveel.Webhooks.csproj | 6 +++--- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/Deveel.Webhooks.DynamicLinq/Deveel.Webhooks.DynamicLinq.csproj b/src/Deveel.Webhooks.DynamicLinq/Deveel.Webhooks.DynamicLinq.csproj index efff094..4e869d3 100644 --- a/src/Deveel.Webhooks.DynamicLinq/Deveel.Webhooks.DynamicLinq.csproj +++ b/src/Deveel.Webhooks.DynamicLinq/Deveel.Webhooks.DynamicLinq.csproj @@ -2,14 +2,14 @@ net6.0 - 1.1.6 + 1.1.7 Deveel false true antonello - Deveel + Deveel AS An engine of the Deveel Webhooks framework that uses the Dynamic LINQ expressions to evaluate filters - Copyright (C) 2021-2022 Deveel + Copyright (C) 2021-2023 Deveel AS LICENSE deveel-logo.png https://github.com/deveel/deveel.webhooks diff --git a/src/Deveel.Webhooks.Model/Deveel.Webhooks.Model.csproj b/src/Deveel.Webhooks.Model/Deveel.Webhooks.Model.csproj index 4b1a61f..6bf63c6 100644 --- a/src/Deveel.Webhooks.Model/Deveel.Webhooks.Model.csproj +++ b/src/Deveel.Webhooks.Model/Deveel.Webhooks.Model.csproj @@ -2,14 +2,14 @@ net6.0 - 1.1.6 + 1.1.7 Deveel false true antonello - Deveel + Deveel AS Abstractions defining the model of the Webhook domain within the Deveel Webhooks Framework - Copyright (C) 2021-2022 Deveel + Copyright (C) 2021-2023 Deveel AS LICENSE https://deveel.com deveel-logo.png diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson.csproj b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson.csproj index 404e679..7d2a5c4 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson.csproj +++ b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson.csproj @@ -4,12 +4,12 @@ net6.0 enable enable - 1.1.6 + 1.1.7 Deveel true antonello - Deveel - Copyright (C) 2021-2023 Deveel + Deveel AS + Copyright (C) 2021-2023 Deveel AS LICENSE deveel-logo.png https://github.com/deveel/deveel.webhooks diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj index 5477f5a..333fc74 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj @@ -4,12 +4,12 @@ net6.0 enable enable - 1.1.6 + 1.1.7 Deveel true antonello - Deveel - Copyright (C) 2021-2022 Deveel + Deveel AS + Copyright (C) 2021-2023 Deveel AS LICENSE deveel-logo.png https://github.com/deveel/deveel.webhooks diff --git a/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.csproj b/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.csproj index 7ba3cba..1721c62 100644 --- a/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.csproj +++ b/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.csproj @@ -2,14 +2,14 @@ net6.0 - 1.1.6 + 1.1.7 Deveel false true antonello - Deveel + Deveel AS An implementation of the Deveel Webhooks storage layer based on MongoDb - Copyright (C) 2021-2022 + Copyright (C) 2021-2023 Deveel AS LICENSE https://deveel.com deveel-logo.png diff --git a/src/Deveel.Webhooks.Service/Deveel.Webhooks.Service.csproj b/src/Deveel.Webhooks.Service/Deveel.Webhooks.Service.csproj index 0cbd2d4..bc3a49d 100644 --- a/src/Deveel.Webhooks.Service/Deveel.Webhooks.Service.csproj +++ b/src/Deveel.Webhooks.Service/Deveel.Webhooks.Service.csproj @@ -2,14 +2,14 @@ net6.0 - 1.1.6 + 1.1.7 Deveel false true antonello - Deveel + Deveel AS Abstractions and default services for the management Webhook subscriptions and their resolution - Copyright (C) 2021-2022 Deveel + Copyright (C) 2021-2023 Deveel AS LICENSE https://deveel.com deveel-logo.png diff --git a/src/Deveel.Webhooks/Deveel.Webhooks.csproj b/src/Deveel.Webhooks/Deveel.Webhooks.csproj index b875f64..74ea4a6 100644 --- a/src/Deveel.Webhooks/Deveel.Webhooks.csproj +++ b/src/Deveel.Webhooks/Deveel.Webhooks.csproj @@ -2,14 +2,14 @@ net6.0 - 1.1.6 + 1.1.7 Deveel false true antonello - Deveel + Deveel AS Abstractions and utilities for the service management of webhooks senders - Copyright (C) 2021-2022 Deveel + Copyright (C) 2021-2023 Deveel LICENSE deveel-logo.png https://github.com/deveel/deveel.webhooks