Skip to content

Commit dab18ce

Browse files
Share captcha validation policy
1 parent 65e0b60 commit dab18ce

12 files changed

Lines changed: 341 additions & 177 deletions
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
using EssentialCSharp.Web.Models;
2+
using EssentialCSharp.Web.Services;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Options;
5+
6+
namespace EssentialCSharp.Web.Tests;
7+
8+
public class CaptchaValidationServiceTests
9+
{
10+
[Test]
11+
public async Task ValidateAsync_Disabled_SkipsVerification()
12+
{
13+
StubCaptchaService captchaService = new((_, _, _) => throw new InvalidOperationException("Verifier should not be called."));
14+
using ServiceProvider serviceProvider = CreateServiceProvider(
15+
new CaptchaOptions { SecretKey = string.Empty, SiteKey = string.Empty },
16+
captchaService);
17+
18+
ICaptchaValidationService validationService = serviceProvider.GetRequiredService<ICaptchaValidationService>();
19+
20+
CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1");
21+
22+
await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Disabled);
23+
await Assert.That(result.ShouldProceed).IsTrue();
24+
await Assert.That(captchaService.CallCount).IsEqualTo(0);
25+
}
26+
27+
[Test]
28+
public async Task ValidateAsync_MissingToken_ReturnsMissingToken()
29+
{
30+
StubCaptchaService captchaService = new((_, _, _) => throw new InvalidOperationException("Verifier should not be called."));
31+
using ServiceProvider serviceProvider = CreateServiceProvider(
32+
new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" },
33+
captchaService);
34+
35+
ICaptchaValidationService validationService = serviceProvider.GetRequiredService<ICaptchaValidationService>();
36+
37+
CaptchaValidationResult result = await validationService.ValidateAsync(string.Empty, "127.0.0.1");
38+
39+
await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.MissingToken);
40+
await Assert.That(result.ShouldProceed).IsFalse();
41+
await Assert.That(captchaService.CallCount).IsEqualTo(0);
42+
}
43+
44+
[Test]
45+
public async Task ValidateAsync_Unavailable_ReturnsUnavailable()
46+
{
47+
StubCaptchaService captchaService = new((_, _, _) => Task.FromResult<HCaptchaResult?>(null));
48+
using ServiceProvider serviceProvider = CreateServiceProvider(
49+
new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" },
50+
captchaService);
51+
52+
ICaptchaValidationService validationService = serviceProvider.GetRequiredService<ICaptchaValidationService>();
53+
54+
CaptchaValidationResult result = await validationService.ValidateAsync("token", "127.0.0.1");
55+
56+
await Assert.That(result.Outcome).IsEqualTo(CaptchaValidationOutcome.Unavailable);
57+
await Assert.That(result.ShouldProceed).IsFalse();
58+
await Assert.That(captchaService.CallCount).IsEqualTo(1);
59+
}
60+
61+
[Test]
62+
public async Task ValidateAsync_InvalidAndValid_ReturnExpectedOutcome()
63+
{
64+
StubCaptchaService invalidCaptchaService = new((_, _, _) => Task.FromResult<HCaptchaResult?>(new HCaptchaResult
65+
{
66+
Success = false,
67+
ErrorCodes = ["invalid-input-response"]
68+
}));
69+
using ServiceProvider invalidProvider = CreateServiceProvider(
70+
new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" },
71+
invalidCaptchaService);
72+
73+
ICaptchaValidationService invalidValidationService = invalidProvider.GetRequiredService<ICaptchaValidationService>();
74+
CaptchaValidationResult invalidResult = await invalidValidationService.ValidateAsync("token", "127.0.0.1");
75+
76+
await Assert.That(invalidResult.Outcome).IsEqualTo(CaptchaValidationOutcome.Invalid);
77+
await Assert.That(invalidResult.Response).IsNotNull();
78+
await Assert.That(invalidResult.ShouldProceed).IsFalse();
79+
80+
StubCaptchaService validCaptchaService = new((_, _, _) => Task.FromResult<HCaptchaResult?>(new HCaptchaResult
81+
{
82+
Success = true
83+
}));
84+
using ServiceProvider validProvider = CreateServiceProvider(
85+
new CaptchaOptions { SecretKey = "secret", SiteKey = "sitekey" },
86+
validCaptchaService);
87+
88+
ICaptchaValidationService validValidationService = validProvider.GetRequiredService<ICaptchaValidationService>();
89+
CaptchaValidationResult validResult = await validValidationService.ValidateAsync("token", "127.0.0.1");
90+
91+
await Assert.That(validResult.Outcome).IsEqualTo(CaptchaValidationOutcome.Valid);
92+
await Assert.That(validResult.ShouldProceed).IsTrue();
93+
await Assert.That(validCaptchaService.CallCount).IsEqualTo(1);
94+
}
95+
96+
private static ServiceProvider CreateServiceProvider(CaptchaOptions options, ICaptchaService captchaService)
97+
{
98+
ServiceCollection services = new();
99+
services.AddSingleton(Options.Create(options));
100+
services.AddSingleton(captchaService);
101+
services.AddSingleton<ICaptchaValidationService, CaptchaValidationService>();
102+
return services.BuildServiceProvider();
103+
}
104+
105+
private sealed class StubCaptchaService(Func<string?, string?, CancellationToken, Task<HCaptchaResult?>> verifyAsync) : ICaptchaService
106+
{
107+
public int CallCount { get; private set; }
108+
109+
public Task<HCaptchaResult?> VerifyAsync(string secret, string response, string sitekey, CancellationToken cancellationToken = default)
110+
=> throw new NotSupportedException();
111+
112+
public Task<HCaptchaResult?> VerifyAsync(string? response, CancellationToken cancellationToken = default)
113+
=> VerifyAsync(response, remoteIp: null, cancellationToken);
114+
115+
public async Task<HCaptchaResult?> VerifyAsync(string? response, string? remoteIp, CancellationToken cancellationToken = default)
116+
{
117+
CallCount++;
118+
return await verifyAsync(response, remoteIp, cancellationToken);
119+
}
120+
}
121+
}

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
using System.ComponentModel.DataAnnotations;
1+
using System.ComponentModel.DataAnnotations;
22
using System.Text;
33
using System.Text.Encodings.Web;
44
using EssentialCSharp.Web.Areas.Identity.Data;
5-
using EssentialCSharp.Web.Models;
65
using EssentialCSharp.Web.Services;
76
using Microsoft.AspNetCore.Identity;
87
using Microsoft.AspNetCore.Identity.UI.Services;
@@ -13,7 +12,7 @@
1312

1413
namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
1514

16-
public class ForgotPasswordModel(UserManager<EssentialCSharpWebUser> userManager, IEmailSender emailSender, ICaptchaService captchaService, IOptions<CaptchaOptions> optionsAccessor) : PageModel
15+
public class ForgotPasswordModel(UserManager<EssentialCSharpWebUser> userManager, IEmailSender emailSender, ICaptchaValidationService captchaValidationService, IOptions<CaptchaOptions> optionsAccessor) : PageModel
1716
{
1817
private InputModel? _Input;
1918
[BindProperty]
@@ -36,8 +35,8 @@ public class InputModel
3635
public async Task<IActionResult> OnPostAsync()
3736
{
3837
string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName];
39-
HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString());
40-
if (captchaResult?.Success != true)
38+
CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString());
39+
if (!captchaResult.ShouldProceed)
4140
{
4241
ModelState.AddModelError(string.Empty, "Human verification failed. Please try again.");
4342
return Page();

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
using System.ComponentModel.DataAnnotations;
1+
using System.ComponentModel.DataAnnotations;
22
using EssentialCSharp.Web.Areas.Identity.Data;
3-
using EssentialCSharp.Web.Models;
43
using EssentialCSharp.Web.Services;
54
using EssentialCSharp.Web.Services.Referrals;
65
using Microsoft.AspNetCore.Authentication;
@@ -11,7 +10,7 @@
1110

1211
namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
1312

14-
public partial class LoginModel(SignInManager<EssentialCSharpWebUser> signInManager, UserManager<EssentialCSharpWebUser> userManager, ILogger<LoginModel> logger, IReferralService referralService, ICaptchaService captchaService, IOptions<CaptchaOptions> optionsAccessor) : PageModel
13+
public partial class LoginModel(SignInManager<EssentialCSharpWebUser> signInManager, UserManager<EssentialCSharpWebUser> userManager, ILogger<LoginModel> logger, IReferralService referralService, ICaptchaValidationService captchaValidationService, IOptions<CaptchaOptions> optionsAccessor) : PageModel
1514
{
1615
private InputModel? _Input;
1716
[BindProperty]
@@ -68,8 +67,8 @@ public async Task<IActionResult> OnPostAsync(string? returnUrl = null)
6867
returnUrl ??= Url.Content("~/");
6968

7069
string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName];
71-
HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString());
72-
if (captchaResult?.Success != true)
70+
CaptchaValidationResult captchaResult = await captchaValidationService.ValidateAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString());
71+
if (!captchaResult.ShouldProceed)
7372
{
7473
ModelState.AddModelError(string.Empty, "Human verification failed. Please try again.");
7574
ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList();

0 commit comments

Comments
 (0)