diff --git a/Directory.Packages.props b/Directory.Packages.props index 438cccb6..82971445 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,65 +1,66 @@  - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/OrchardCoreContrib.Modules.sln b/OrchardCoreContrib.Modules.sln index 05619217..27ccb2f8 100644 --- a/OrchardCoreContrib.Modules.sln +++ b/OrchardCoreContrib.Modules.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31912.275 +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11312.151 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C80A325F-F4C4-4C7B-A3CF-FB77CD8C9949}" EndProject @@ -78,6 +78,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCoreContrib.UserGrou EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCoreContrib.UserGroups.Tests", "test\OrchardCoreContrib.UserGroups.Tests\OrchardCoreContrib.UserGroups.Tests.csproj", "{689A006D-0992-456E-86C9-66B7A2EE06A5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCoreContrib.CloudflareTurnstile", "src\OrchardCoreContrib.CloudflareTurnstile\OrchardCoreContrib.CloudflareTurnstile.csproj", "{1CF93DCB-2DF2-4E42-A37E-744F38D347C5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCoreContrib.CloudflareTurnstile.Tests", "test\OrchardCoreContrib.CloudflareTurnstile.Tests\OrchardCoreContrib.CloudflareTurnstile.Tests.csproj", "{B2029246-BC23-4C18-B71A-55F13CB9E7AB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -204,6 +208,14 @@ Global {689A006D-0992-456E-86C9-66B7A2EE06A5}.Debug|Any CPU.Build.0 = Debug|Any CPU {689A006D-0992-456E-86C9-66B7A2EE06A5}.Release|Any CPU.ActiveCfg = Release|Any CPU {689A006D-0992-456E-86C9-66B7A2EE06A5}.Release|Any CPU.Build.0 = Release|Any CPU + {1CF93DCB-2DF2-4E42-A37E-744F38D347C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1CF93DCB-2DF2-4E42-A37E-744F38D347C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1CF93DCB-2DF2-4E42-A37E-744F38D347C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1CF93DCB-2DF2-4E42-A37E-744F38D347C5}.Release|Any CPU.Build.0 = Release|Any CPU + {B2029246-BC23-4C18-B71A-55F13CB9E7AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2029246-BC23-4C18-B71A-55F13CB9E7AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2029246-BC23-4C18-B71A-55F13CB9E7AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2029246-BC23-4C18-B71A-55F13CB9E7AB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -239,6 +251,8 @@ Global {330877B1-7BE6-4B57-8714-2DADE1C53563} = {C80A325F-F4C4-4C7B-A3CF-FB77CD8C9949} {C76B5687-3079-4E80-B7D2-B691F2B05A8A} = {C80A325F-F4C4-4C7B-A3CF-FB77CD8C9949} {689A006D-0992-456E-86C9-66B7A2EE06A5} = {A239BFB0-9BA7-467C-AD41-405D0740633F} + {1CF93DCB-2DF2-4E42-A37E-744F38D347C5} = {C80A325F-F4C4-4C7B-A3CF-FB77CD8C9949} + {B2029246-BC23-4C18-B71A-55F13CB9E7AB} = {A239BFB0-9BA7-467C-AD41-405D0740633F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {48F73B05-7D3D-4ACF-81AE-A98B2B4EFDB2} diff --git a/README.md b/README.md index 2132c9e2..a8a457fe 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The `OrchardCoreContrib.Modules` repository consists of the following modules: | Name | Namespace | NuGet | | --- | --- | --- | | [Swagger Module](src/OrchardCoreContrib.Apis.Swagger/README.md) | `OrchardCoreContrib.Apis.Swagger` | [![NuGet](https://img.shields.io/nuget/v/OrchardCoreContrib.Apis.Swagger.svg)](https://www.nuget.org/packages/OrchardCoreContrib.Apis.Swagger) | +| [Cloudflare Turnstile Module](src/OrchardCoreContrib.CloudflareTurnstile/README.md) | `OrchardCoreContrib.CloudflareTurnstile` | [![NuGet](https://img.shields.io/nuget/v/OrchardCoreContrib.CloudflareTurnstile.svg)](https://www.nuget.org/packages/OrchardCoreContrib.CloudflareTurnstile) | | [Content Localization Module](src/OrchardCoreContrib.ContentLocalization/README.md) | `OrchardCoreContrib.ContentLocalization` | [![NuGet](https://img.shields.io/nuget/v/OrchardCoreContrib.ContentLocalization.svg)](https://www.nuget.org/packages/OrchardCoreContrib.ContentLocalization) | | [Content Preview Module](src/OrchardCoreContrib.ContentPreview/README.md) | `OrchardCoreContrib.ContentPreview` | [![NuGet](https://img.shields.io/nuget/v/OrchardCoreContrib.ContentPreview.svg)](https://www.nuget.org/packages/OrchardCoreContrib.ContentPreview) | | [Content Permissions Module](src/OrchardCoreContrib.ContentPermissions/README.md) | `OrchardCoreContrib.Permissions` | [![NuGet](https://img.shields.io/nuget/v/OrchardCoreContrib.ContentPermissions.svg)](https://www.nuget.org/packages/OrchardCoreContrib.ContentPermissions) | diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/AdminMenu.cs b/src/OrchardCoreContrib.CloudflareTurnstile/AdminMenu.cs new file mode 100644 index 00000000..b23075d7 --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/AdminMenu.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using OrchardCore.Navigation; +using OrchardCoreContrib.CloudflareTurnstile.Drivers; + +namespace OrchardCoreContrib.CloudflareTurnstile; + +using OrchardCoreContrib.Navigation; + +public sealed class AdminMenu(IStringLocalizer S) : AdminNavigationProvider +{ + private static readonly RouteValueDictionary _routeValues = new() + { + { "area", "OrchardCore.Settings" }, + { "groupId", TurnstileSettingsDisplayDriver.GroupId }, + }; + + public override void BuildNavigation(NavigationBuilder builder) + { + builder + .Add(S["Configuration"], configuration => configuration + .Add(S["Settings"], settings => settings + .Add(S["Security"], S["Security"].PrefixPosition(), security => security + .Add(S["Turnstile"], S["Turnstile"].PrefixPosition(), turnstile => turnstile + .Id("turnstile") + .Permission(TurnstilePermissions.ManageTurnstileSettings) + .Action("Index", "Admin", _routeValues) + .LocalNav() + ) + ) + ) + ); + } +} diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/Configuration/TurnstileOptions.cs b/src/OrchardCoreContrib.CloudflareTurnstile/Configuration/TurnstileOptions.cs new file mode 100644 index 00000000..b4408232 --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/Configuration/TurnstileOptions.cs @@ -0,0 +1,13 @@ +namespace OrchardCoreContrib.CloudflareTurnstile.Configuration; + +public class TurnstileOptions +{ + public string SiteKey { get; set; } + + public string SecretKey { get; set; } + + public string Theme { get; set; } + + public string Size { get; set; } +} + diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/Configuration/TurnstileOptionsConfiguration.cs b/src/OrchardCoreContrib.CloudflareTurnstile/Configuration/TurnstileOptionsConfiguration.cs new file mode 100644 index 00000000..653b8abf --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/Configuration/TurnstileOptionsConfiguration.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Settings; +using OrchardCoreContrib.CloudflareTurnstile.Settings; + +namespace OrchardCoreContrib.CloudflareTurnstile.Configuration; + +public class TurnstileOptionsConfiguration( + ISiteService siteService, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) : IConfigureOptions +{ + public const string ProtectorName = "TurnstileSettingsConfiguration"; + + public void Configure(TurnstileOptions options) + { + var settings = siteService.GetSettings(); + + var protector = dataProtectionProvider.CreateProtector(ProtectorName); + + if (!string.IsNullOrWhiteSpace(settings.SiteKey)) + { + try + { + options.SiteKey = protector.Unprotect(settings.SiteKey); + } + catch + { + logger.LogError("The site key could not be decrypted. It may have been encrypted using a different key."); + } + } + + if (!string.IsNullOrWhiteSpace(settings.SecretKey)) + { + try + { + options.SecretKey = protector.Unprotect(settings.SecretKey); + } + catch + { + logger.LogError("The secret key could not be decrypted. It may have been encrypted using a different key."); + } + } + + options.Theme = settings.Theme; + options.Size = settings.Size; + } +} diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/Constants.cs b/src/OrchardCoreContrib.CloudflareTurnstile/Constants.cs new file mode 100644 index 00000000..0878426d --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/Constants.cs @@ -0,0 +1,10 @@ +namespace OrchardCoreContrib.CloudflareTurnstile; + +public static class Constants +{ + public const string TurnstileScriptUri = "https://challenges.cloudflare.com/turnstile/v0/api.js"; + + public const string TurnstileApiUri = "https://challenges.cloudflare.com/turnstile/v0/api/"; + + public const string TurnstileServerResponseHeaderName = "cf-turnstile-response"; +} diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/Drivers/TurnstileSettingsDisplayDriver.cs b/src/OrchardCoreContrib.CloudflareTurnstile/Drivers/TurnstileSettingsDisplayDriver.cs new file mode 100644 index 00000000..6d7f87c6 --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/Drivers/TurnstileSettingsDisplayDriver.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using OrchardCore.DisplayManagement.Entities; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Environment.Shell; +using OrchardCore.Settings; +using OrchardCoreContrib.CloudflareTurnstile.Configuration; +using OrchardCoreContrib.CloudflareTurnstile.Settings; +using OrchardCoreContrib.CloudflareTurnstile.ViewModels; + +namespace OrchardCoreContrib.CloudflareTurnstile.Drivers; + +public class TurnstileSettingsDisplayDriver( + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService, + IShellReleaseManager shellReleaseManager, + IDataProtectionProvider dataProtectionProvider) : SiteDisplayDriver +{ + public const string GroupId = "Turnstile"; + + protected override string SettingsGroupId => GroupId; + + public async override Task EditAsync(ISite site, TurnstileSettings settings, BuildEditorContext context) + { + var user = httpContextAccessor.HttpContext.User; + + if (!await authorizationService.AuthorizeAsync(user, TurnstilePermissions.ManageTurnstileSettings)) + { + return null; + } + + context.AddTenantReloadWarningWrapper(); + + return Initialize("TurnstileSettings_Edit", model => + { + model.SiteKey = settings.SiteKey; + model.SecretKey = settings.SecretKey; + model.Theme = settings.Theme; + model.Size = settings.Size; + }).Location("Content:5") + .OnGroup(GroupId); + } + + public async override Task UpdateAsync(ISite site, TurnstileSettings settings, UpdateEditorContext context) + { + var user = httpContextAccessor.HttpContext.User; + + if (!await authorizationService.AuthorizeAsync(user, TurnstilePermissions.ManageTurnstileSettings)) + { + return null; + } + + var model = new TurnstileSettingsViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + var protector = dataProtectionProvider.CreateProtector(TurnstileOptionsConfiguration.ProtectorName); + + if (!string.IsNullOrWhiteSpace(model.SiteKey)) + { + if (settings.SiteKey != model.SiteKey) + { + settings.SiteKey = protector.Protect(model.SiteKey); + } + } + + if (!string.IsNullOrWhiteSpace(model.SecretKey)) + { + if (settings.SecretKey != model.SecretKey) + { + settings.SecretKey = protector.Protect(model.SecretKey); + } + } + + settings.Theme = model.Theme; + settings.Size = model.Size; + + if (context.Updater.ModelState.IsValid) + { + shellReleaseManager.RequestRelease(); + } + + return await EditAsync(site, settings, context); + } +} + diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/Manifest.cs b/src/OrchardCoreContrib.CloudflareTurnstile/Manifest.cs new file mode 100644 index 00000000..62989e34 --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/Manifest.cs @@ -0,0 +1,11 @@ +using OrchardCore.Modules.Manifest; +using ManifestConstants = OrchardCoreContrib.Modules.Manifest.ManifestConstants; + +[assembly: Module( + Name = "Cloudflare Turnstile", + Author = ManifestConstants.Author, + Website = ManifestConstants.Website, + Version = "1.0.0", + Description = "Provides Cloudflare Turnstile Captcha.", + Category = "Security" +)] diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/OrchardCoreContrib.CloudflareTurnstile.csproj b/src/OrchardCoreContrib.CloudflareTurnstile/OrchardCoreContrib.CloudflareTurnstile.csproj new file mode 100644 index 00000000..b9b53066 --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/OrchardCoreContrib.CloudflareTurnstile.csproj @@ -0,0 +1,45 @@ + + + + true + 1.0.0 + The Orchard Core Contrib Team + + Cloudflare Turnstile Captcha. + BSD-3-Clause + https://github.com/OrchardCoreContrib/OrchardCoreContrib.Modules/tree/main/src/OrchardCoreContrib.CloudflareTurnstile/README.md + https://github.com/OrchardCoreContrib/OrchardCoreContrib.Modules + git + true + Orchard Core, Orchard Core Contrib, Cloudflare Turnstile + https://github.com/OrchardCoreContrib/OrchardCoreContrib.Modules/releases + OrchardCoreContrib.CloudflareTurnstile + icon.png + README.md + Orchard Core Contrib Cloudflare Turnstile Module + true + 2019 Orchard Core Contrib + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/Permissions.cs b/src/OrchardCoreContrib.CloudflareTurnstile/Permissions.cs new file mode 100644 index 00000000..4efe637b --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/Permissions.cs @@ -0,0 +1,24 @@ +using OrchardCore; +using OrchardCore.Security.Permissions; + +namespace OrchardCoreContrib.CloudflareTurnstile; + +public sealed class Permissions : IPermissionProvider +{ + private readonly IEnumerable _allPermissions = + [ + TurnstilePermissions.ManageTurnstileSettings, + ]; + + public Task> GetPermissionsAsync() + => Task.FromResult(_allPermissions); + + public IEnumerable GetDefaultStereotypes() => + [ + new PermissionStereotype + { + Name = OrchardCoreConstants.Roles.Administrator, + Permissions = _allPermissions, + }, + ]; +} diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/README.md b/src/OrchardCoreContrib.CloudflareTurnstile/README.md new file mode 100644 index 00000000..5d1f70ac --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/README.md @@ -0,0 +1,68 @@ +# Cloudflare Turnstile Module + +This module allows you to protect your forms by Cloudflare Turnstile Captcha. + +## Version + +1.0.0 + +## Category + +Security + +## Dependencies + +This module has no dependencies. + +## Features + +| | | +|------------------|---------------------------------------------------------------| +| **Name** | Cloudflare Turnstile (`OrchardCoreContrib.CloudflareTurnstile`) | +| **Description** | Provides Cloudflare Turnstile Captcha. | +| **Dependencies** | | + +## NuGet Packages + +| Name | Version | +|-----------------------------------------------------------------------------------------------------------------------|-------------| +| [`OrchardCoreContrib.CloudflareTurnstile`](https://www.nuget.org/packages/OrchardCoreContrib.CloudflareTurnstile/1.0.0) | 1.0.0 | + +## Get Started + +1. Install the [`OrchardCoreContrib.CloudflareTurnstile`](https://www.nuget.org/packages/OrchardCoreContrib.CloudflareTurnstile/) NuGet package to your Orchard Core host project. +2. Go to the admin site +3. Select **Configuration -> Features** menu. +4. Enable the `Cloudflare Turnstile` feature. +5. Now you can protect your forms as the following: + + 5.1. In your view place the `Turnstile` shape to render the Cloudflare Turnstile widget + + ```html +
+ @Html.AntiForgeryToken() + + + +
+ ``` + + 5.2. In the controller you need to inject the `TurnstileService` to validate the token, that comes from the form + + ```csharp + [Route("/subscribe")] + [HttpPost] + public async Task Index( + [FromForm(Name = Constants.TurnstileServerResponseHeaderName)] string token, + [FromServices] TurnstileService captcha) + { + if (!await captcha.ValidateAsync(token)) + { + return BadRequest("Captcha failed"); + } + + // Proceed with form logic + + return Ok("Success"); + } + ``` diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/Services/TurnstileResponse.cs b/src/OrchardCoreContrib.CloudflareTurnstile/Services/TurnstileResponse.cs new file mode 100644 index 00000000..2ebb76b6 --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/Services/TurnstileResponse.cs @@ -0,0 +1,8 @@ +namespace OrchardCoreContrib.CloudflareTurnstile.Services; + +public class TurnstileResponse +{ + public bool Success { get; set; } + + public string[] ErrorCodes { get; set; } +} diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/Services/TurnstileService.cs b/src/OrchardCoreContrib.CloudflareTurnstile/Services/TurnstileService.cs new file mode 100644 index 00000000..8b45233c --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/Services/TurnstileService.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Options; +using OrchardCoreContrib.CloudflareTurnstile.Configuration; +using System.Net.Http.Json; + +namespace OrchardCoreContrib.CloudflareTurnstile.Services; + +public class TurnstileService( + IHttpClientFactory httpClientFactory, + IOptions options) +{ + private readonly TurnstileOptions options = options.Value; + + public async Task ValidateAsync(string token) + { + var client = httpClientFactory.CreateClient(); + var content = new FormUrlEncodedContent(new Dictionary + { + ["secret"] = options.SecretKey, + ["response"] = token + }); + + var response = await client.PostAsync(Constants.TurnstileApiUri, content); + + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadFromJsonAsync(); + + return json.Success; + } + + return false; + } +} diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/Services/TurnstileShape.cs b/src/OrchardCoreContrib.CloudflareTurnstile/Services/TurnstileShape.cs new file mode 100644 index 00000000..ef42118b --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/Services/TurnstileShape.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Options; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.Descriptors; +using OrchardCore.Modules; +using OrchardCore.ResourceManagement; +using OrchardCoreContrib.CloudflareTurnstile.Configuration; + +namespace OrchardCoreContrib.CloudflareTurnstile.Services; + +[Feature("OrchardCoreContrib.CloudflareTurnstile")] +public sealed class TurnstileShape(IResourceManager resourceManager, IOptions options) : IShapeAttributeProvider +{ + private readonly TurnstileOptions options = options.Value; + + [Shape] + public async Task Turnstile() + { + var script = new TagBuilder("script"); + script.MergeAttribute("src", Constants.TurnstileScriptUri); + + resourceManager.RegisterHeadScript(script); + + var div = new TagBuilder("div"); + div.AddCssClass("cf-turnstile"); + div.MergeAttribute("data-sitekey", options.SiteKey); + div.MergeAttribute("data-theme", options.Theme); + div.MergeAttribute("data-size", options.Size); + + return div; + } +} diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/Settings/TurnstileSettings.cs b/src/OrchardCoreContrib.CloudflareTurnstile/Settings/TurnstileSettings.cs new file mode 100644 index 00000000..30797442 --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/Settings/TurnstileSettings.cs @@ -0,0 +1,19 @@ +namespace OrchardCoreContrib.CloudflareTurnstile.Settings; + +public class TurnstileSettings +{ + public string SiteKey { get; set; } + + public string SecretKey { get; set; } + + /// + /// Theme options: "light", "dark", "auto" + /// + public string Theme { get; set; } = "light"; + + /// + /// Size options: "normal", "compact" + /// + public string Size { get; set; } = "normal"; +} + diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/Startup.cs b/src/OrchardCoreContrib.CloudflareTurnstile/Startup.cs new file mode 100644 index 00000000..fac4f225 --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/Startup.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; +using Microsoft.Extensions.Options; +using OrchardCore.DisplayManagement.Descriptors; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.Modules; +using OrchardCore.Navigation; +using OrchardCore.Security.Permissions; +using OrchardCoreContrib.CloudflareTurnstile.Configuration; +using OrchardCoreContrib.CloudflareTurnstile.Drivers; +using OrchardCoreContrib.CloudflareTurnstile.Services; +using Polly; + +namespace OrchardCoreContrib.CloudflareTurnstile; + +public sealed class Startup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddShapeAttributes(); + + services + .AddHttpClient(nameof(TurnstileService)) + .AddResilienceHandler("occ-handler", builder => builder + .AddRetry(new HttpRetryStrategyOptions + { + Name = "occ-retry", + MaxRetryAttempts = 3, + OnRetry = attempt => + { + attempt.RetryDelay.Add(TimeSpan.FromSeconds(0.5 * attempt.AttemptNumber)); + + return ValueTask.CompletedTask; + }, + }) + ); + + services.AddTransient, TurnstileOptionsConfiguration>(); + + services.AddSiteDisplayDriver(); + services.AddNavigationProvider(); + services.AddPermissionProvider(); + } +} + diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/TurnstilePermissions.cs b/src/OrchardCoreContrib.CloudflareTurnstile/TurnstilePermissions.cs new file mode 100644 index 00000000..b0d293f1 --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/TurnstilePermissions.cs @@ -0,0 +1,8 @@ +using OrchardCore.Security.Permissions; + +namespace OrchardCoreContrib.CloudflareTurnstile; + +public static class TurnstilePermissions +{ + public static readonly Permission ManageTurnstileSettings = new("ManageTurnstileSettings", "Manage Turnstile Settings"); +} diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/ViewModels/TurnstileSettingsViewModel.cs b/src/OrchardCoreContrib.CloudflareTurnstile/ViewModels/TurnstileSettingsViewModel.cs new file mode 100644 index 00000000..b670e131 --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/ViewModels/TurnstileSettingsViewModel.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace OrchardCoreContrib.CloudflareTurnstile.ViewModels; + +public class TurnstileSettingsViewModel +{ + [Required] + public string SiteKey { get; set; } + + [Required] + public string SecretKey { get; set; } + + public string Theme { get; set; } + + public string Size { get; set; } +} + diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/Views/NavigationItemText-turnstile.Id.cshtml b/src/OrchardCoreContrib.CloudflareTurnstile/Views/NavigationItemText-turnstile.Id.cshtml new file mode 100644 index 00000000..d1557514 --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/Views/NavigationItemText-turnstile.Id.cshtml @@ -0,0 +1,4 @@ + + + +@T["Cloudflare Turnstile"] diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/Views/TurnstileSettings.Edit.cshtml b/src/OrchardCoreContrib.CloudflareTurnstile/Views/TurnstileSettings.Edit.cshtml new file mode 100644 index 00000000..9d8c6d3f --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/Views/TurnstileSettings.Edit.cshtml @@ -0,0 +1,38 @@ +@model TurnstileSettingsViewModel +@using OrchardCore.DisplayManagement.Views +@using OrchardCoreContrib.CloudflareTurnstile.ViewModels + +
+ + + +
+ +
+ + + +
+ +
+ + + + @T["Choose the Turnstile widget theme."] + +
+ +
+ + + + @T["Choose the Turnstile widget size."] + +
diff --git a/src/OrchardCoreContrib.CloudflareTurnstile/Views/_ViewImports.cshtml b/src/OrchardCoreContrib.CloudflareTurnstile/Views/_ViewImports.cshtml new file mode 100644 index 00000000..61b0660c --- /dev/null +++ b/src/OrchardCoreContrib.CloudflareTurnstile/Views/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@inherits OrchardCore.DisplayManagement.Razor.RazorPage +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, OrchardCore.DisplayManagement +@addTagHelper *, OrchardCore.ResourceManagement diff --git a/src/OrchardCoreContrib.Modules.Web/OrchardCoreContrib.Modules.Web.csproj b/src/OrchardCoreContrib.Modules.Web/OrchardCoreContrib.Modules.Web.csproj index 4e2f9f0c..119fc20b 100644 --- a/src/OrchardCoreContrib.Modules.Web/OrchardCoreContrib.Modules.Web.csproj +++ b/src/OrchardCoreContrib.Modules.Web/OrchardCoreContrib.Modules.Web.csproj @@ -8,6 +8,7 @@ + diff --git a/test/OrchardCoreContrib.CloudflareTurnstile.Tests/OrchardCoreContrib.CloudflareTurnstile.Tests.csproj b/test/OrchardCoreContrib.CloudflareTurnstile.Tests/OrchardCoreContrib.CloudflareTurnstile.Tests.csproj new file mode 100644 index 00000000..0ad711ec --- /dev/null +++ b/test/OrchardCoreContrib.CloudflareTurnstile.Tests/OrchardCoreContrib.CloudflareTurnstile.Tests.csproj @@ -0,0 +1,29 @@ + + + + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/test/OrchardCoreContrib.CloudflareTurnstile.Tests/Services/TurnstileServiceTests.cs b/test/OrchardCoreContrib.CloudflareTurnstile.Tests/Services/TurnstileServiceTests.cs new file mode 100644 index 00000000..c7ea0f3c --- /dev/null +++ b/test/OrchardCoreContrib.CloudflareTurnstile.Tests/Services/TurnstileServiceTests.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.Options; +using Moq; +using Moq.Protected; +using OrchardCoreContrib.CloudflareTurnstile.Configuration; +using System.Net; +using System.Text; +using System.Text.Json; + +namespace OrchardCoreContrib.CloudflareTurnstile.Services.Tests; + +public class TurnstileServiceTests +{ + private static readonly IOptions _turnstileOptions = Options.Create(new TurnstileOptions + { + SecretKey = "secret" + }); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ValidateAsync_ReturnsBasedOnResponseSuccessResult(bool success) + { + // Arrange + var content = JsonSerializer.Serialize(new TurnstileResponse { Success = success }); + + var responseMessage = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(content , Encoding.UTF8, "application/json") + }; + + var service = new TurnstileService(CreateHttpClientFactory(responseMessage), _turnstileOptions); + + // Act + var result = await service.ValidateAsync("token"); + + // Assert + Assert.Equal(success, result); + } + + [Fact] + public async Task ValidateAsync_ReturnsFalse_WhenApiReturnsNonSuccessStatusCode() + { + // Arrange + var service = new TurnstileService(CreateHttpClientFactory(HttpStatusCode.BadRequest), _turnstileOptions); + + // Act + var result = await service.ValidateAsync("token"); + + // Assert + Assert.False(result); + } + + private sealed class CaptureHandler(HttpResponseMessage response) : HttpMessageHandler + { + public HttpRequestMessage Request { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Request = request; + + return Task.FromResult(response); + } + } + + private static IHttpClientFactory CreateHttpClientFactory(HttpResponseMessage httpReponseMessage) + { + var httpMessageHandlerMock = new Mock(); + + httpMessageHandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(httpReponseMessage); + + var client = new HttpClient(httpMessageHandlerMock.Object); + + var httpClientFactoryMock = new Mock(); + + httpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny())) + .Returns(client); + + return httpClientFactoryMock.Object; + } + + private static IHttpClientFactory CreateHttpClientFactory(HttpStatusCode httpStatusCode) + => CreateHttpClientFactory(new HttpResponseMessage(httpStatusCode)); +}