Skip to content

Commit b31d6ce

Browse files
Add captcha to Login, ResendEmailConfirmation, and ResetPassword pages
Co-authored-by: BenjaminMichaelis <[email protected]>
1 parent 0d84c90 commit b31d6ce

File tree

10 files changed

+309
-85
lines changed

10 files changed

+309
-85
lines changed

EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFramework>net8.0</TargetFramework>
4+
<TargetFramework>net9.0</TargetFramework>
55

66
<IsPackable>false</IsPackable>
77
<IsPublishable>false</IsPublishable>

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
@Html.DisplayNameFor(m => m.Input.RememberMe)
3030
</label>
3131
</div>
32+
<div class="form-group">
33+
<div class="h-captcha" data-sitekey=@Model.CaptchaOptions.SiteKey></div>
34+
</div>
3235
<div>
3336
<button id="login-submit" type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
3437
</div>
@@ -82,5 +85,6 @@
8285
</div>
8386

8487
@section Scripts {
88+
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
8589
<partial name="_ValidationScriptsPartial" />
8690
}

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

Lines changed: 111 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
using System.ComponentModel.DataAnnotations;
22
using EssentialCSharp.Web.Areas.Identity.Data;
3+
using EssentialCSharp.Web.Models;
4+
using EssentialCSharp.Web.Services;
35
using EssentialCSharp.Web.Services.Referrals;
46
using Microsoft.AspNetCore.Authentication;
57
using Microsoft.AspNetCore.Identity;
68
using Microsoft.AspNetCore.Mvc;
79
using Microsoft.AspNetCore.Mvc.RazorPages;
10+
using Microsoft.Extensions.Options;
811

912
namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
1013

11-
public class LoginModel(SignInManager<EssentialCSharpWebUser> signInManager, UserManager<EssentialCSharpWebUser> userManager, ILogger<LoginModel> logger, IReferralService referralService) : PageModel
14+
public class LoginModel(SignInManager<EssentialCSharpWebUser> signInManager, UserManager<EssentialCSharpWebUser> userManager, ILogger<LoginModel> logger, IReferralService referralService, ICaptchaService captchaService, IOptions<CaptchaOptions> optionsAccessor) : PageModel
1215
{
16+
public CaptchaOptions CaptchaOptions { get; } = optionsAccessor.Value;
1317
private InputModel? _Input;
1418
[BindProperty]
1519
public InputModel Input
@@ -60,49 +64,119 @@ public async Task OnGetAsync(string? returnUrl = null)
6064
public async Task<IActionResult> OnPostAsync(string? returnUrl = null)
6165
{
6266
returnUrl ??= Url.Content("~/");
67+
string? hCaptcha_response = Request.Form[CaptchaOptions.HttpPostResponseKeyName];
6368

6469
ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
6570

66-
if (ModelState.IsValid)
71+
if (!ModelState.IsValid)
6772
{
68-
Microsoft.AspNetCore.Identity.SignInResult result;
69-
if (Input.Email is null)
70-
{
71-
return RedirectToPage(Url.Content("~/"), new { ReturnUrl = returnUrl });
72-
}
73-
EssentialCSharpWebUser? foundUser = await userManager.FindByEmailAsync(Input.Email);
74-
if (Input.Password is null)
75-
{
76-
return RedirectToPage(Url.Content("~/"), new { ReturnUrl = returnUrl });
77-
}
78-
if (foundUser is not null)
79-
{
80-
result = await signInManager.PasswordSignInAsync(foundUser, Input.Password, Input.RememberMe, lockoutOnFailure: true);
81-
// Call the referral service to get the referral ID and set it onto the user claim
82-
_ = await referralService.EnsureReferralIdAsync(foundUser);
83-
}
84-
else
85-
{
86-
result = await signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: true);
87-
}
88-
if (result.Succeeded)
89-
{
90-
logger.LogInformation("User logged in.");
91-
return LocalRedirect(returnUrl);
92-
}
93-
if (result.RequiresTwoFactor)
94-
{
95-
return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
96-
}
97-
if (result.IsLockedOut)
73+
return Page();
74+
}
75+
76+
if (hCaptcha_response is null)
77+
{
78+
ModelState.AddModelError(CaptchaOptions.HttpPostResponseKeyName, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription);
79+
return Page();
80+
}
81+
82+
HCaptchaResult? response = await captchaService.VerifyAsync(hCaptcha_response);
83+
if (response is null)
84+
{
85+
ModelState.AddModelError(CaptchaOptions.HttpPostResponseKeyName, "Error: HCaptcha API response unexpectedly null");
86+
return Page();
87+
}
88+
89+
if (response.Success)
90+
{
91+
if (ModelState.IsValid)
9892
{
99-
logger.LogWarning("User account locked out.");
100-
return RedirectToPage("./Lockout");
93+
Microsoft.AspNetCore.Identity.SignInResult result;
94+
if (Input.Email is null)
95+
{
96+
return RedirectToPage(Url.Content("~/"), new { ReturnUrl = returnUrl });
97+
}
98+
EssentialCSharpWebUser? foundUser = await userManager.FindByEmailAsync(Input.Email);
99+
if (Input.Password is null)
100+
{
101+
return RedirectToPage(Url.Content("~/"), new { ReturnUrl = returnUrl });
102+
}
103+
if (foundUser is not null)
104+
{
105+
result = await signInManager.PasswordSignInAsync(foundUser, Input.Password, Input.RememberMe, lockoutOnFailure: true);
106+
// Call the referral service to get the referral ID and set it onto the user claim
107+
_ = await referralService.EnsureReferralIdAsync(foundUser);
108+
}
109+
else
110+
{
111+
result = await signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: true);
112+
}
113+
if (result.Succeeded)
114+
{
115+
logger.LogInformation("User logged in.");
116+
return LocalRedirect(returnUrl);
117+
}
118+
if (result.RequiresTwoFactor)
119+
{
120+
return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
121+
}
122+
if (result.IsLockedOut)
123+
{
124+
logger.LogWarning("User account locked out.");
125+
return RedirectToPage("./Lockout");
126+
}
127+
else
128+
{
129+
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
130+
return Page();
131+
}
101132
}
102-
else
133+
}
134+
else
135+
{
136+
switch (response.ErrorCodes?.Length)
103137
{
104-
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
105-
return Page();
138+
case 0:
139+
throw new InvalidOperationException("The HCaptcha determined the passcode is not valid, and does not meet the security criteria");
140+
case > 1:
141+
throw new InvalidOperationException("HCaptcha returned error codes: " + string.Join(", ", response.ErrorCodes));
142+
default:
143+
{
144+
if (response.ErrorCodes is null)
145+
{
146+
throw new InvalidOperationException("HCaptcha returned error codes unexpectedly null");
147+
}
148+
if (HCaptchaErrorDetails.TryGetValue(response.ErrorCodes.Single(), out HCaptchaErrorDetails? details))
149+
{
150+
switch (details.ErrorCode)
151+
{
152+
case HCaptchaErrorDetails.MissingInputResponse:
153+
case HCaptchaErrorDetails.InvalidInputResponse:
154+
case HCaptchaErrorDetails.InvalidOrAlreadySeenResponse:
155+
ModelState.AddModelError(string.Empty, details.FriendlyDescription);
156+
logger.LogInformation("HCaptcha returned error code: {ErrorDetails}", details.ToString());
157+
break;
158+
case HCaptchaErrorDetails.BadRequest:
159+
ModelState.AddModelError(string.Empty, details.FriendlyDescription);
160+
logger.LogInformation("HCaptcha returned error code: {ErrorDetails}", details.ToString());
161+
break;
162+
case HCaptchaErrorDetails.MissingInputSecret:
163+
case HCaptchaErrorDetails.InvalidInputSecret:
164+
case HCaptchaErrorDetails.NotUsingDummyPasscode:
165+
case HCaptchaErrorDetails.SitekeySecretMismatch:
166+
logger.LogCritical("HCaptcha returned error code: {ErrorDetails}", details.ToString());
167+
break;
168+
default:
169+
throw new InvalidOperationException("HCaptcha returned unknown error code: " + details?.ErrorCode);
170+
}
171+
}
172+
else
173+
{
174+
throw new InvalidOperationException("HCaptcha returned unknown error code: " + response.ErrorCodes.Single());
175+
}
176+
177+
break;
178+
}
179+
106180
}
107181
}
108182

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@
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 class="form-group">
20+
<div class="h-captcha" data-sitekey=@Model.CaptchaOptions.SiteKey></div>
21+
</div>
1922
<button type="submit" class="w-100 btn btn-lg btn-primary">Resend</button>
2023
</form>
2124
</div>
2225
</div>
2326

2427
@section Scripts {
28+
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
2529
<partial name="_ValidationScriptsPartial" />
2630
}

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

Lines changed: 91 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@
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.Authorization;
68
using Microsoft.AspNetCore.Identity;
79
using Microsoft.AspNetCore.Identity.UI.Services;
810
using Microsoft.AspNetCore.Mvc;
911
using Microsoft.AspNetCore.Mvc.RazorPages;
1012
using Microsoft.AspNetCore.WebUtilities;
13+
using Microsoft.Extensions.Options;
1114

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

1417
[AllowAnonymous]
15-
public class ResendEmailConfirmationModel(UserManager<EssentialCSharpWebUser> userManager, IEmailSender emailSender) : PageModel
18+
public class ResendEmailConfirmationModel(UserManager<EssentialCSharpWebUser> userManager, IEmailSender emailSender, ICaptchaService captchaService, IOptions<CaptchaOptions> optionsAccessor) : PageModel
1619
{
20+
public CaptchaOptions CaptchaOptions { get; } = optionsAccessor.Value;
1721
private InputModel? _Input;
1822
[BindProperty]
1923
public InputModel Input
@@ -31,43 +35,108 @@ public class InputModel
3135

3236
public async Task<IActionResult> OnPostAsync()
3337
{
38+
string? hCaptcha_response = Request.Form[CaptchaOptions.HttpPostResponseKeyName];
39+
3440
if (!ModelState.IsValid)
3541
{
3642
return Page();
3743
}
3844

39-
if (Input.Email is null)
45+
if (hCaptcha_response is null)
4046
{
41-
ModelState.AddModelError(string.Empty, "Error: Email is null. Please enter in an email");
47+
ModelState.AddModelError(CaptchaOptions.HttpPostResponseKeyName, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription);
4248
return Page();
4349
}
4450

45-
EssentialCSharpWebUser? user = await userManager.FindByEmailAsync(Input.Email);
46-
if (user is null)
51+
HCaptchaResult? response = await captchaService.VerifyAsync(hCaptcha_response);
52+
if (response is null)
4753
{
48-
return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
54+
ModelState.AddModelError(CaptchaOptions.HttpPostResponseKeyName, "Error: HCaptcha API response unexpectedly null");
55+
return Page();
4956
}
5057

51-
string userId = await userManager.GetUserIdAsync(user);
52-
string code = await userManager.GenerateEmailConfirmationTokenAsync(user);
53-
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
54-
string? callbackUrl = Url.Page(
55-
"/Account/ConfirmEmail",
56-
pageHandler: null,
57-
values: new { userId = userId, code = code },
58-
protocol: Request.Scheme);
59-
60-
if (callbackUrl is null)
58+
if (response.Success)
6159
{
62-
ModelState.AddModelError(string.Empty, "Error: callback url unexpectedly null.");
60+
if (Input.Email is null)
61+
{
62+
ModelState.AddModelError(string.Empty, "Error: Email is null. Please enter in an email");
63+
return Page();
64+
}
65+
66+
EssentialCSharpWebUser? user = await userManager.FindByEmailAsync(Input.Email);
67+
if (user is null)
68+
{
69+
return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
70+
}
71+
72+
string userId = await userManager.GetUserIdAsync(user);
73+
string code = await userManager.GenerateEmailConfirmationTokenAsync(user);
74+
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
75+
string? callbackUrl = Url.Page(
76+
"/Account/ConfirmEmail",
77+
pageHandler: null,
78+
values: new { userId = userId, code = code },
79+
protocol: Request.Scheme);
80+
81+
if (callbackUrl is null)
82+
{
83+
ModelState.AddModelError(string.Empty, "Error: callback url unexpectedly null.");
84+
return Page();
85+
}
86+
await emailSender.SendEmailAsync(
87+
Input.Email,
88+
"Confirm your email",
89+
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
90+
91+
ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email. If you can't find the email, please check your spam folder.");
6392
return Page();
6493
}
65-
await emailSender.SendEmailAsync(
66-
Input.Email,
67-
"Confirm your email",
68-
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
94+
else
95+
{
96+
switch (response.ErrorCodes?.Length)
97+
{
98+
case 0:
99+
throw new InvalidOperationException("The HCaptcha determined the passcode is not valid, and does not meet the security criteria");
100+
case > 1:
101+
throw new InvalidOperationException("HCaptcha returned error codes: " + string.Join(", ", response.ErrorCodes));
102+
default:
103+
{
104+
if (response.ErrorCodes is null)
105+
{
106+
throw new InvalidOperationException("HCaptcha returned error codes unexpectedly null");
107+
}
108+
if (HCaptchaErrorDetails.TryGetValue(response.ErrorCodes.Single(), out HCaptchaErrorDetails? details))
109+
{
110+
switch (details.ErrorCode)
111+
{
112+
case HCaptchaErrorDetails.MissingInputResponse:
113+
case HCaptchaErrorDetails.InvalidInputResponse:
114+
case HCaptchaErrorDetails.InvalidOrAlreadySeenResponse:
115+
ModelState.AddModelError(string.Empty, details.FriendlyDescription);
116+
break;
117+
case HCaptchaErrorDetails.BadRequest:
118+
ModelState.AddModelError(string.Empty, details.FriendlyDescription);
119+
break;
120+
case HCaptchaErrorDetails.MissingInputSecret:
121+
case HCaptchaErrorDetails.InvalidInputSecret:
122+
case HCaptchaErrorDetails.NotUsingDummyPasscode:
123+
case HCaptchaErrorDetails.SitekeySecretMismatch:
124+
break;
125+
default:
126+
throw new InvalidOperationException("HCaptcha returned unknown error code: " + details?.ErrorCode);
127+
}
128+
}
129+
else
130+
{
131+
throw new InvalidOperationException("HCaptcha returned unknown error code: " + response.ErrorCodes.Single());
132+
}
133+
134+
break;
135+
}
136+
137+
}
138+
}
69139

70-
ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email. If you can't find the email, please check your spam folder.");
71140
return Page();
72141
}
73142
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,15 @@
2727
<label asp-for="Input.ConfirmPassword" class="form-label"></label>
2828
<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
2929
</div>
30+
<div class="form-group">
31+
<div class="h-captcha" data-sitekey=@Model.CaptchaOptions.SiteKey></div>
32+
</div>
3033
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset</button>
3134
</form>
3235
</div>
3336
</div>
3437

3538
@section Scripts {
39+
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
3640
<partial name="_ValidationScriptsPartial" />
3741
}

0 commit comments

Comments
 (0)