Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion PC2/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
using PC2.Services;
using System.Globalization;
using Microsoft.Extensions.Azure;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
Expand All @@ -21,6 +20,10 @@
// Register AnalyticsService for DI
builder.Services.AddScoped<AnalyticsService>();

// Register ReCaptchaService for DI
builder.Services.AddHttpClient();
builder.Services.AddScoped<IReCaptchaService, ReCaptchaService>();

// Configure Application Insights - only add if connection string is provided
var appInsightsConnectionString = builder.Configuration.GetSection("APPLICATIONINSIGHTS_CONNECTION_STRING").Value;
builder.Services.AddApplicationInsightsTelemetry(options =>
Expand Down
103 changes: 103 additions & 0 deletions PC2/Services/ReCaptchaService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System.Text.Json.Serialization;

namespace PC2.Services;

public interface IReCaptchaService
{
/// <summary>
/// Verifies a Google reCAPTCHA v3 token with Google's API.
/// </summary>
/// <param name="token">The reCAPTCHA token from the client-side submission.</param>
/// <returns>True if the token is valid and the score meets the minimum threshold; otherwise false.</returns>
Task<bool> 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<ReCaptchaService> _logger;
private readonly string _secretKey;
private readonly float _minimumScore;

public ReCaptchaService(IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<ReCaptchaService> 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<bool> 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<string, string>("secret", _secretKey),
new KeyValuePair<string, string>("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<ReCaptchaResponse>();
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<string>? ErrorCodes { get; set; }
}
20 changes: 20 additions & 0 deletions PC2/Views/Shared/_ReCaptchaScriptsPartial.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
@inject IConfiguration Configuration
@{
var siteKey = Configuration["GoogleReCaptcha:SiteKey"];
}

@if (!string.IsNullOrEmpty(siteKey))
{
<script src="https://www.google.com/recaptcha/api.js?render=@siteKey"></script>
<script>
function getReCaptchaToken(action) {
return new Promise((resolve) => {
grecaptcha.ready(function () {
grecaptcha.execute('@siteKey', { action: action }).then(function (token) {
resolve(token);
});
});
});
}
</script>
}
5 changes: 5 additions & 0 deletions PC2/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
22 changes: 22 additions & 0 deletions PC2Tests/ConfigTests/AppSettingsConfigTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" "<your-site-key>"
dotnet user-secrets set "GoogleReCaptcha:SecretKey" "<your-secret-key>"
```
3. For production, set these values in Azure App Service application settings or Key Vault.

## Admin Credentials
- Username: `admin@pc2online.org`
- Password: `Password01#`
Expand Down