Skip to content

Commit 0179313

Browse files
Add in better captchas
1 parent cc37f90 commit 0179313

22 files changed

Lines changed: 589 additions & 93 deletions

.github/workflows/Build-Test-And-Deploy.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ jobs:
164164
AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey APPLICATIONINSIGHTS_CONNECTION_STRING=secretref:appinsights-connectionstring \
165165
AIOptions__Endpoint=secretref:ai-endpoint AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \
166166
AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \
167-
TryDotNet__Origin=$TRYDOTNET_ORIGIN DataProtection__AzureKeyVaultKeyUri=$KEYVAULTURI/keys/dataprotection
167+
TryDotNet__Origin=$TRYDOTNET_ORIGIN DataProtection__AzureKeyVaultKeyUri=$KEYVAULTURI/keys/dataprotection \
168+
HCaptcha__ExpectedHostname=staging.essentialcsharp.com
168169
- name: Logout of Azure CLI
169170
if: always()
170171
uses: azure/CLI@v3
@@ -255,8 +256,8 @@ jobs:
255256
AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey APPLICATIONINSIGHTS_CONNECTION_STRING=secretref:appinsights-connectionstring \
256257
AIOptions__Endpoint=secretref:ai-endpoint AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \
257258
AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \
258-
TryDotNet__Origin=$TRYDOTNET_ORIGIN DataProtection__AzureKeyVaultKeyUri=$KEYVAULTURI/keys/dataprotection
259-
259+
TryDotNet__Origin=$TRYDOTNET_ORIGIN DataProtection__AzureKeyVaultKeyUri=$KEYVAULTURI/keys/dataprotection \
260+
HCaptcha__ExpectedHostname=essentialcsharp.com
260261
261262
- name: Logout of Azure CLI
262263
if: always()
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using System.Threading.RateLimiting;
2+
using EssentialCSharp.Web.Services;
3+
4+
namespace EssentialCSharp.Web.Tests;
5+
6+
/// <summary>
7+
/// Unit tests for <see cref="CombinedRateLimiter"/> — no HTTP, no factory.
8+
/// AutoReplenishment is disabled so permits are consumed deterministically without timer callbacks.
9+
/// </summary>
10+
public class CombinedRateLimiterTests
11+
{
12+
private static CombinedRateLimiter CreateLimiter(int perMinute, int perHour) =>
13+
new(
14+
new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
15+
{
16+
PermitLimit = perMinute,
17+
Window = TimeSpan.FromMinutes(1),
18+
SegmentsPerWindow = 3,
19+
AutoReplenishment = false,
20+
QueueLimit = 0
21+
}),
22+
new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions
23+
{
24+
PermitLimit = perHour,
25+
Window = TimeSpan.FromHours(1),
26+
AutoReplenishment = false,
27+
QueueLimit = 0
28+
})
29+
);
30+
31+
[Test]
32+
public async Task PerMinuteLimitIsEnforced_WhenPerHourIsSufficient()
33+
{
34+
using var limiter = CreateLimiter(perMinute: 3, perHour: 1000);
35+
36+
for (int i = 0; i < 3; i++)
37+
{
38+
using var lease = limiter.AttemptAcquire();
39+
await Assert.That(lease.IsAcquired).IsTrue();
40+
}
41+
42+
using var rejected = limiter.AttemptAcquire();
43+
await Assert.That(rejected.IsAcquired).IsFalse();
44+
}
45+
46+
[Test]
47+
public async Task PerHourLimitIsEnforced_WhenPerMinuteIsSufficient()
48+
{
49+
using var limiter = CreateLimiter(perMinute: 1000, perHour: 3);
50+
51+
for (int i = 0; i < 3; i++)
52+
{
53+
using var lease = limiter.AttemptAcquire();
54+
await Assert.That(lease.IsAcquired).IsTrue();
55+
}
56+
57+
using var rejected = limiter.AttemptAcquire();
58+
await Assert.That(rejected.IsAcquired).IsFalse();
59+
}
60+
61+
[Test]
62+
public async Task BothLimitsPass_AcquiredLeaseIsSuccessful()
63+
{
64+
using var limiter = CreateLimiter(perMinute: 5, perHour: 5);
65+
66+
using var lease = limiter.AttemptAcquire();
67+
await Assert.That(lease.IsAcquired).IsTrue();
68+
}
69+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System.Net;
2+
using Microsoft.AspNetCore.Mvc.Testing;
3+
4+
namespace EssentialCSharp.Web.Tests;
5+
6+
/// <summary>
7+
/// HTTP integration tests for the "content" rate limit policy.
8+
/// Uses its own factory (PerClass) to get a fresh in-memory rate limiter for each run.
9+
/// Anonymous users are limited to 10 requests per minute on chapter content pages.
10+
/// </summary>
11+
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerClass)]
12+
public class ContentRateLimitingTests(WebApplicationFactory factory)
13+
{
14+
[Test]
15+
public async Task ContentEndpoint_ExceedingPerMinuteLimit_Returns429()
16+
{
17+
// AllowAutoRedirect = false prevents redirect-following from consuming extra permits.
18+
HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions
19+
{
20+
AllowAutoRedirect = false
21+
});
22+
23+
// Anonymous limit is 10/min. First 10 requests should not be rate-limited.
24+
for (int i = 0; i < 10; i++)
25+
{
26+
using HttpResponseMessage response = await client.GetAsync("/hello-world");
27+
await Assert.That(response.StatusCode)
28+
.IsNotEqualTo(HttpStatusCode.TooManyRequests)
29+
.Because($"request {i + 1} of 10 should be within the rate limit");
30+
}
31+
32+
// 11th request must be rejected by the content rate limiter.
33+
using HttpResponseMessage rateLimited = await client.GetAsync("/hello-world");
34+
await Assert.That(rateLimited.StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests);
35+
}
36+
}

EssentialCSharp.Web.Tests/Integration/CaptchaTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public class CaptchaTests(CaptchaServiceProvider serviceProvider)
1212
[Test]
1313
public async Task CaptchaService_Verify_Success(CancellationToken cancellationToken)
1414
{
15-
ICaptchaService captchaService = serviceProvider.ServiceProvider.GetRequiredService<ICaptchaService>();
15+
CaptchaService captchaService = (CaptchaService)serviceProvider.ServiceProvider.GetRequiredService<ICaptchaService>();
1616

1717
// From https://docs.hcaptcha.com/#integration-testing-test-keys
1818
string hCaptchaSecret = "0x0000000000000000000000000000000000000000";

EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,48 @@
99
<hr />
1010
<div class="row">
1111
<div class="col-md-4">
12-
<form method="post">
12+
<form id="forgot-password-form" method="post">
1313
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
1414
<div class="form-floating mb-3">
1515
<input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
1616
<label asp-for="Input.Email" class="form-label"></label>
1717
<span asp-validation-for="Input.Email" class="text-danger"></span>
1818
</div>
19+
<div id="forgot-password-captcha"></div>
20+
<p class="small text-muted mt-2">
21+
This site is protected by hCaptcha and its
22+
<a href="https://www.hcaptcha.com/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>
23+
and <a href="https://www.hcaptcha.com/terms" target="_blank" rel="noopener noreferrer">Terms of Service</a>
24+
apply.
25+
</p>
1926
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset Password</button>
2027
</form>
2128
</div>
2229
</div>
2330

2431
@section Scripts {
2532
<partial name="_ValidationScriptsPartial" />
33+
<script>
34+
(function () {
35+
let forgotPwWidgetId;
36+
let captchaSolved = false;
37+
const form = document.getElementById('forgot-password-form');
38+
39+
document.addEventListener('DOMContentLoaded', function () {
40+
forgotPwWidgetId = hcaptcha.render('forgot-password-captcha', {
41+
sitekey: '@Model.CaptchaSiteKey',
42+
size: 'invisible',
43+
callback: function () { captchaSolved = true; form.requestSubmit(); },
44+
'expired-callback': function () { captchaSolved = false; },
45+
'error-callback': function () { captchaSolved = false; }
46+
});
47+
form.addEventListener('submit', function (e) {
48+
if (!captchaSolved) {
49+
e.preventDefault();
50+
if (!window.jQuery || $(form).valid()) { hcaptcha.execute(forgotPwWidgetId); }
51+
}
52+
});
53+
});
54+
})();
55+
</script>
2656
}

EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22
using System.Text;
33
using System.Text.Encodings.Web;
44
using EssentialCSharp.Web.Areas.Identity.Data;
5+
using EssentialCSharp.Web.Models;
6+
using EssentialCSharp.Web.Services;
57
using Microsoft.AspNetCore.Identity;
68
using Microsoft.AspNetCore.Identity.UI.Services;
79
using Microsoft.AspNetCore.Mvc;
810
using Microsoft.AspNetCore.Mvc.RazorPages;
911
using Microsoft.AspNetCore.WebUtilities;
12+
using Microsoft.Extensions.Options;
1013

1114
namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
1215

13-
public class ForgotPasswordModel(UserManager<EssentialCSharpWebUser> userManager, IEmailSender emailSender) : PageModel
16+
public class ForgotPasswordModel(UserManager<EssentialCSharpWebUser> userManager, IEmailSender emailSender, ICaptchaService captchaService, IOptions<CaptchaOptions> optionsAccessor) : PageModel
1417
{
1518
private InputModel? _Input;
1619
[BindProperty]
@@ -20,6 +23,8 @@ public InputModel Input
2023
set => _Input = value ?? throw new ArgumentNullException(nameof(value));
2124
}
2225

26+
public string CaptchaSiteKey { get; } = optionsAccessor.Value.SiteKey ?? string.Empty;
27+
2328
public class InputModel
2429
{
2530

@@ -30,6 +35,14 @@ public class InputModel
3035

3136
public async Task<IActionResult> OnPostAsync()
3237
{
38+
string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName];
39+
HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString());
40+
if (captchaResult?.Success != true)
41+
{
42+
ModelState.AddModelError(string.Empty, "Human verification failed. Please try again.");
43+
return Page();
44+
}
45+
3346
if (ModelState.IsValid)
3447
{
3548
if (Input.Email is null)

EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@
3030
</label>
3131
</div>
3232
<div>
33+
<div id="login-captcha"></div>
34+
<p class="small text-muted mt-2">
35+
This site is protected by hCaptcha and its
36+
<a href="https://www.hcaptcha.com/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>
37+
and <a href="https://www.hcaptcha.com/terms" target="_blank" rel="noopener noreferrer">Terms of Service</a>
38+
apply.
39+
</p>
3340
<button id="login-submit" type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
3441
</div>
3542
<div>
@@ -83,4 +90,27 @@
8390

8491
@section Scripts {
8592
<partial name="_ValidationScriptsPartial" />
93+
<script>
94+
(function () {
95+
let loginWidgetId;
96+
let captchaSolved = false;
97+
const form = document.getElementById('account');
98+
99+
document.addEventListener('DOMContentLoaded', function () {
100+
loginWidgetId = hcaptcha.render('login-captcha', {
101+
sitekey: '@Model.CaptchaSiteKey',
102+
size: 'invisible',
103+
callback: function () { captchaSolved = true; form.requestSubmit(); },
104+
'expired-callback': function () { captchaSolved = false; },
105+
'error-callback': function () { captchaSolved = false; }
106+
});
107+
form.addEventListener('submit', function (e) {
108+
if (!captchaSolved) {
109+
e.preventDefault();
110+
if (!window.jQuery || $(form).valid()) { hcaptcha.execute(loginWidgetId); }
111+
}
112+
});
113+
});
114+
})();
115+
</script>
86116
}

EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
4848
</div>
4949
<div class="form-group">
50-
<div class="h-captcha" data-sitekey=@Model.CaptchaOptions.SiteKey></div>
50+
<partial name="_HCaptchaWidget" model='new EssentialCSharp.Web.Models.HCaptchaWidgetModel(Model.CaptchaSiteKey)' />
5151
</div>
5252
<button id="registerSubmit" type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
5353
</form>
@@ -90,7 +90,6 @@
9090
</div>
9191

9292
@section Scripts {
93-
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
9493
<partial name="_ValidationScriptsPartial" />
9594
<script type="module" src="~/js/password-strength.js" asp-append-version="true"></script>
9695
}

0 commit comments

Comments
 (0)