diff --git a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Forms/ReCaptchaPartDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Forms/ReCaptchaPartDisplayDriver.cs index 7b203b6e67e..aa1a5ef6534 100644 --- a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Forms/ReCaptchaPartDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Forms/ReCaptchaPartDisplayDriver.cs @@ -1,5 +1,3 @@ -using System; -using System.Threading.Tasks; using OrchardCore.ContentManagement.Display.ContentDisplay; using OrchardCore.ContentManagement.Display.Models; using OrchardCore.DisplayManagement.Views; @@ -20,21 +18,21 @@ public ReCaptchaPartDisplayDriver(ISiteService siteService) public override IDisplayResult Display(ReCaptchaPart part, BuildPartDisplayContext context) { - return Initialize("ReCaptchaPart", (Func)(async m => + return Initialize("ReCaptchaPart", async model => { var siteSettings = await _siteService.GetSiteSettingsAsync(); var settings = siteSettings.As(); - m.SettingsAreConfigured = settings.IsValid(); - })).Location("Detail", "Content"); + model.SettingsAreConfigured = settings.IsValid(); + }).Location("Detail", "Content"); } public override IDisplayResult Edit(ReCaptchaPart part, BuildPartEditorContext context) { - return Initialize("ReCaptchaPart_Fields_Edit", async m => + return Initialize("ReCaptchaPart_Fields_Edit", async model => { var siteSettings = await _siteService.GetSiteSettingsAsync(); var settings = siteSettings.As(); - m.SettingsAreConfigured = settings.IsValid(); + model.SettingsAreConfigured = settings.IsValid(); }); } } diff --git a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Startup.cs b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Startup.cs index 68c7c569b9d..0e55ae780bd 100644 --- a/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.ReCaptcha/Startup.cs @@ -47,7 +47,7 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); services.Configure((options) => { - options.Filters.Add(typeof(ReCaptchaLoginFilter)); + options.Filters.Add(); }); } } diff --git a/src/OrchardCore/OrchardCore.ReCaptcha.Core/Configuration/ReCaptchaSettings.cs b/src/OrchardCore/OrchardCore.ReCaptcha.Core/Configuration/ReCaptchaSettings.cs index 3ffb729a6ab..9ef25e413fb 100644 --- a/src/OrchardCore/OrchardCore.ReCaptcha.Core/Configuration/ReCaptchaSettings.cs +++ b/src/OrchardCore/OrchardCore.ReCaptcha.Core/Configuration/ReCaptchaSettings.cs @@ -1,5 +1,3 @@ -using System; - namespace OrchardCore.ReCaptcha.Configuration { public class ReCaptchaSettings @@ -17,9 +15,11 @@ public class ReCaptchaSettings /// public int DetectionThreshold { get; set; } = 5; + private bool? _isValid; + public bool IsValid() - { - return !string.IsNullOrWhiteSpace(SiteKey) && !string.IsNullOrWhiteSpace(SecretKey); - } + => _isValid ??= !string.IsNullOrWhiteSpace(SiteKey) + && !string.IsNullOrWhiteSpace(SecretKey) + && !string.IsNullOrWhiteSpace(ReCaptchaApiUri); } } diff --git a/src/OrchardCore/OrchardCore.ReCaptcha.Core/ServiceCollectionExtensions.cs b/src/OrchardCore/OrchardCore.ReCaptcha.Core/ServiceCollectionExtensions.cs index 7167a21f82a..9b750f5cc63 100644 --- a/src/OrchardCore/OrchardCore.ReCaptcha.Core/ServiceCollectionExtensions.cs +++ b/src/OrchardCore/OrchardCore.ReCaptcha.Core/ServiceCollectionExtensions.cs @@ -13,12 +13,14 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddReCaptcha(this IServiceCollection services, Action configure = null) { - services.AddHttpClient() + // c.f. https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests + services.AddScoped() + .AddHttpClient() .AddTransientHttpErrorPolicy(policy => policy.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(0.5 * attempt))); services.AddSingleton(); services.AddTransient, ReCaptchaSettingsConfiguration>(); - services.AddSingleton(); + services.AddTagHelpers(); if (configure != null) diff --git a/src/OrchardCore/OrchardCore.ReCaptcha.Core/Services/ReCaptchaClient.cs b/src/OrchardCore/OrchardCore.ReCaptcha.Core/Services/ReCaptchaClient.cs deleted file mode 100644 index 20836a58af3..00000000000 --- a/src/OrchardCore/OrchardCore.ReCaptcha.Core/Services/ReCaptchaClient.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Newtonsoft.Json.Linq; -using OrchardCore.ReCaptcha.Configuration; - -namespace OrchardCore.ReCaptcha.Services -{ - public class ReCaptchaClient - { - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - - public ReCaptchaClient(HttpClient httpClient, IOptions optionsAccessor, ILogger logger) - { - var options = optionsAccessor.Value; - _httpClient = httpClient; - _httpClient.BaseAddress = new Uri(options.ReCaptchaApiUri); - _logger = logger; - } - - /// - /// Verifies the supplied token with ReCaptcha Api. - /// - /// Token received from the ReCaptcha UI. - /// Key entered by user in the secrets. - /// A boolean indicating if the token is valid. - public async Task VerifyAsync(string responseToken, string secretKey) - { - if (string.IsNullOrWhiteSpace(responseToken)) - { - return false; - } - - var content = new FormUrlEncodedContent(new Dictionary - { - { "secret", secretKey }, - { "response", responseToken } - }); - try - { - var response = await _httpClient.PostAsync("siteverify", content); - response.EnsureSuccessStatusCode(); - var responseJson = await response.Content.ReadAsStringAsync(); - var responseModel = JObject.Parse(responseJson); - - return responseModel["success"].Value(); - } - catch (HttpRequestException e) - { - _logger.LogError(e, "Could not contact Google to verify captcha."); - } - - return false; - } - } -} diff --git a/src/OrchardCore/OrchardCore.ReCaptcha.Core/Services/ReCaptchaResponse.cs b/src/OrchardCore/OrchardCore.ReCaptcha.Core/Services/ReCaptchaResponse.cs new file mode 100644 index 00000000000..699ec2cdcd0 --- /dev/null +++ b/src/OrchardCore/OrchardCore.ReCaptcha.Core/Services/ReCaptchaResponse.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.ReCaptcha.Services; + +public class ReCaptchaResponse +{ + public bool Success { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.ReCaptcha.Core/Services/ReCaptchaService.cs b/src/OrchardCore/OrchardCore.ReCaptcha.Core/Services/ReCaptchaService.cs index c1c01667664..d73d10c5a07 100644 --- a/src/OrchardCore/OrchardCore.ReCaptcha.Core/Services/ReCaptchaService.cs +++ b/src/OrchardCore/OrchardCore.ReCaptcha.Core/Services/ReCaptchaService.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -15,20 +17,28 @@ namespace OrchardCore.ReCaptcha.Services { public class ReCaptchaService { - private readonly ReCaptchaSettings _settings; + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + PropertyNamingPolicy = SnakeCaseNamingPolicy.Instance, + }; + + private readonly ReCaptchaSettings _reCaptchaSettings; + private readonly HttpClient _httpClient; private readonly IEnumerable _robotDetectors; private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger _logger; protected readonly IStringLocalizer S; public ReCaptchaService( + HttpClient httpClient, IOptions optionsAccessor, IEnumerable robotDetectors, IHttpContextAccessor httpContextAccessor, ILogger logger, IStringLocalizer stringLocalizer) { - _settings = optionsAccessor.Value; + _reCaptchaSettings = optionsAccessor.Value; + _httpClient = httpClient; _robotDetectors = robotDetectors; _httpContextAccessor = httpContextAccessor; _logger = logger; @@ -39,44 +49,33 @@ public ReCaptchaService( /// Flags the behavior as that of a robot. /// public void MaybeThisIsARobot() - { - _robotDetectors.Invoke(i => i.FlagAsRobot(), _logger); - } + => _robotDetectors.Invoke(i => i.FlagAsRobot(), _logger); /// /// Determines if the request has been made by a robot. /// /// Yes (true) or no (false). public bool IsThisARobot() - { - var result = _robotDetectors.Invoke(i => i.DetectRobot(), _logger); - return result.Any(a => a.IsRobot); - } + => _robotDetectors.Invoke(i => i.DetectRobot(), _logger) + .Any(a => a.IsRobot); /// /// Clears all robot markers, we are dealing with a human. /// /// public void ThisIsAHuman() - { - _robotDetectors.Invoke(i => i.IsNotARobot(), _logger); - } + => _robotDetectors.Invoke(i => i.IsNotARobot(), _logger); /// /// Verifies the ReCaptcha response with the ReCaptcha webservice. /// /// /// - public Task VerifyCaptchaResponseAsync(string reCaptchaResponse) - { - if (string.IsNullOrWhiteSpace(reCaptchaResponse)) - { - return Task.FromResult(false); - } + public async Task VerifyCaptchaResponseAsync(string reCaptchaResponse) + => !string.IsNullOrWhiteSpace(reCaptchaResponse) + && _reCaptchaSettings.IsValid() + && await VerifyAsync(reCaptchaResponse); - var reCaptchaClient = _httpContextAccessor.HttpContext.RequestServices.GetRequiredService(); - return reCaptchaClient.VerifyAsync(reCaptchaResponse, _settings.SecretKey); - } /// /// Validates the captcha that is in the Form of the current request. @@ -84,9 +83,9 @@ public Task VerifyCaptchaResponseAsync(string reCaptchaResponse) /// Lambda for reporting errors. public async Task ValidateCaptchaAsync(Action reportError) { - if (!_settings.IsValid()) + if (!_reCaptchaSettings.IsValid()) { - _logger.LogWarning("The ReCaptcha settings are not valid"); + _logger.LogWarning("The ReCaptcha settings are invalid"); return false; } @@ -99,14 +98,43 @@ public async Task ValidateCaptchaAsync(Action reportError) reCaptchaResponse = _httpContextAccessor.HttpContext.Request.Form[Constants.ReCaptchaServerResponseHeaderName].ToString(); } - var isValid = !string.IsNullOrEmpty(reCaptchaResponse) && await VerifyCaptchaResponseAsync(reCaptchaResponse); + var isValid = await VerifyCaptchaResponseAsync(reCaptchaResponse); if (!isValid) { - reportError("ReCaptcha", S["Failed to validate captcha"]); + reportError("ReCaptcha", S["Failed to validate ReCaptcha"]); } return isValid; } + + /// + /// Verifies the supplied token with ReCaptcha Api. + /// + /// Token received from the ReCaptcha UI. + /// A boolean indicating if the token is valid. + private async Task VerifyAsync(string responseToken) + { + try + { + var content = new FormUrlEncodedContent(new Dictionary + { + { "secret", _reCaptchaSettings.SecretKey }, + { "response", responseToken } + }); + + var response = await _httpClient.PostAsync($"{_reCaptchaSettings.ReCaptchaApiUri.TrimEnd('/')}/siteverify", content); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(_jsonSerializerOptions); + + return result.Success; + } + catch (HttpRequestException e) + { + _logger.LogError(e, "Could not contact Google to verify ReCaptcha."); + } + + return false; + } } }