diff --git a/PC2/Program.cs b/PC2/Program.cs index 73fe07d..2cb16d8 100644 --- a/PC2/Program.cs +++ b/PC2/Program.cs @@ -6,7 +6,6 @@ using PC2.Services; using System.Globalization; using Microsoft.Extensions.Azure; - var builder = WebApplication.CreateBuilder(args); // Add services to the container. @@ -21,6 +20,10 @@ // Register AnalyticsService for DI builder.Services.AddScoped(); +// Register ReCaptchaService for DI +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); + // Configure Application Insights - only add if connection string is provided var appInsightsConnectionString = builder.Configuration.GetSection("APPLICATIONINSIGHTS_CONNECTION_STRING").Value; builder.Services.AddApplicationInsightsTelemetry(options => diff --git a/PC2/Services/ReCaptchaService.cs b/PC2/Services/ReCaptchaService.cs new file mode 100644 index 0000000..8b13a57 --- /dev/null +++ b/PC2/Services/ReCaptchaService.cs @@ -0,0 +1,103 @@ +using System.Text.Json.Serialization; + +namespace PC2.Services; + +public interface IReCaptchaService +{ + /// + /// Verifies a Google reCAPTCHA v3 token with Google's API. + /// + /// The reCAPTCHA token from the client-side submission. + /// True if the token is valid and the score meets the minimum threshold; otherwise false. + Task VerifyAsync(string token); +} + +public class ReCaptchaService : IReCaptchaService +{ + private const string VerifyUrl = "https://www.google.com/recaptcha/api/siteverify"; + private const float DefaultMinimumScore = 0.5f; + + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly string _secretKey; + private readonly float _minimumScore; + + public ReCaptchaService(IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + _secretKey = configuration["GoogleReCaptcha:SecretKey"] ?? string.Empty; + _minimumScore = float.TryParse(configuration["GoogleReCaptcha:MinimumScore"], out float score) + ? score + : DefaultMinimumScore; + } + + public async Task VerifyAsync(string token) + { + if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(_secretKey)) + { + _logger.LogWarning("reCAPTCHA verification skipped: token or secret key is missing."); + return false; + } + + try + { + var client = _httpClientFactory.CreateClient(); + var response = await client.PostAsync(VerifyUrl, + new FormUrlEncodedContent(new[] + { + new KeyValuePair("secret", _secretKey), + new KeyValuePair("response", token) + })); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("reCAPTCHA verification request failed with HTTP status {StatusCode}.", response.StatusCode); + return false; + } + + var result = await response.Content.ReadFromJsonAsync(); + if (result is null) + { + _logger.LogWarning("reCAPTCHA verification returned a null response."); + return false; + } + + if (!result.Success) + { + _logger.LogWarning("reCAPTCHA verification failed. Error codes: {ErrorCodes}", + result.ErrorCodes != null ? string.Join(", ", result.ErrorCodes) : "none"); + return false; + } + + if (result.Score < _minimumScore) + { + _logger.LogWarning("reCAPTCHA score {Score} is below the minimum threshold of {MinimumScore}.", + result.Score, _minimumScore); + return false; + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while verifying reCAPTCHA token."); + return false; + } + } +} + +internal class ReCaptchaResponse +{ + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("score")] + public float Score { get; set; } + + [JsonPropertyName("action")] + public string? Action { get; set; } + + [JsonPropertyName("error-codes")] + public List? ErrorCodes { get; set; } +} diff --git a/PC2/Views/Shared/_ReCaptchaScriptsPartial.cshtml b/PC2/Views/Shared/_ReCaptchaScriptsPartial.cshtml new file mode 100644 index 0000000..737f6b0 --- /dev/null +++ b/PC2/Views/Shared/_ReCaptchaScriptsPartial.cshtml @@ -0,0 +1,20 @@ +@inject IConfiguration Configuration +@{ + var siteKey = Configuration["GoogleReCaptcha:SiteKey"]; +} + +@if (!string.IsNullOrEmpty(siteKey)) +{ + + +} diff --git a/PC2/appsettings.json b/PC2/appsettings.json index d546ef5..74aafe6 100644 --- a/PC2/appsettings.json +++ b/PC2/appsettings.json @@ -2,6 +2,11 @@ "PC2SendGridAPIKey": "Set in secrets", "PC2Email": "Set business email for information requests in secrets", "PC2NoReplyEmail": "Set no reply email in secrets", + "GoogleReCaptcha": { + "SiteKey": "Set in secrets", + "SecretKey": "Set in secrets", + "MinimumScore": "0.5" + }, "APPLICATIONINSIGHTS_CONNECTION_STRING": "configured in Azure", "ApplicationInsights": { "WorkspaceId": "Set your Application Insights Workspace ID here or in secrets" diff --git a/PC2Tests/ConfigTests/AppSettingsConfigTests.cs b/PC2Tests/ConfigTests/AppSettingsConfigTests.cs index 5b7be64..777e13e 100644 --- a/PC2Tests/ConfigTests/AppSettingsConfigTests.cs +++ b/PC2Tests/ConfigTests/AppSettingsConfigTests.cs @@ -60,4 +60,26 @@ public void AzureBlob_BlobServiceUri_IsPresentAndNotEmpty() var value = _config.GetSection("AzureBlob")["BlobServiceUri"]; Assert.IsFalse(string.IsNullOrWhiteSpace(value), "AzureBlob:BlobServiceUri is missing or empty in appsettings.json"); } + + [TestMethod] + public void GoogleReCaptcha_SiteKey_IsPresent() + { + var value = _config["GoogleReCaptcha:SiteKey"]; + Assert.IsNotNull(value, "GoogleReCaptcha:SiteKey is missing in appsettings.json"); + } + + [TestMethod] + public void GoogleReCaptcha_SecretKey_IsPresent() + { + var value = _config["GoogleReCaptcha:SecretKey"]; + Assert.IsNotNull(value, "GoogleReCaptcha:SecretKey is missing in appsettings.json"); + } + + [TestMethod] + public void GoogleReCaptcha_MinimumScore_IsPresentAndValid() + { + var value = _config["GoogleReCaptcha:MinimumScore"]; + Assert.IsNotNull(value, "GoogleReCaptcha:MinimumScore is missing in appsettings.json"); + Assert.IsTrue(float.TryParse(value, out _), "GoogleReCaptcha:MinimumScore must be a valid number in appsettings.json"); + } } diff --git a/README.md b/README.md index c93bced..5c316bc 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,16 @@ is hosted on Azure SQL Database. ### Azure Blob Storage Azurite emulator is included as a dependency and runs automatically in Visual Studio. See [Azurite documentation](https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite?tabs=visual-studio) for details. +### Google reCAPTCHA +Google reCAPTCHA v3 is used for spam protection on forms. To configure it for local development: +1. Register a site at [Google reCAPTCHA Admin Console](https://www.google.com/recaptcha/admin) using **reCAPTCHA v3** and `localhost` as an allowed domain. +2. Store your keys in [user secrets](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets) for the `PC2` project: + ``` + dotnet user-secrets set "GoogleReCaptcha:SiteKey" "" + dotnet user-secrets set "GoogleReCaptcha:SecretKey" "" + ``` +3. For production, set these values in Azure App Service application settings or Key Vault. + ## Admin Credentials - Username: `admin@pc2online.org` - Password: `Password01#`