Skip to content

Add Captcha to Necessary Secure Account Areas #752

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
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
4 changes: 4 additions & 0 deletions EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
@Html.DisplayNameFor(m => m.Input.RememberMe)
</label>
</div>
<div class="form-group">
<div class="h-captcha" data-sitekey=@Model.CaptchaOptions.SiteKey></div>
</div>
<div>
<button id="login-submit" type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</div>
Expand Down Expand Up @@ -82,5 +85,6 @@
</div>

@section Scripts {
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
<partial name="_ValidationScriptsPartial" />
}
148 changes: 111 additions & 37 deletions EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
using System.ComponentModel.DataAnnotations;
using EssentialCSharp.Web.Areas.Identity.Data;
using EssentialCSharp.Web.Models;
using EssentialCSharp.Web.Services;
using EssentialCSharp.Web.Services.Referrals;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Options;

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

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

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

if (ModelState.IsValid)
if (!ModelState.IsValid)
{
Microsoft.AspNetCore.Identity.SignInResult result;
if (Input.Email is null)
{
return RedirectToPage(Url.Content("~/"), new { ReturnUrl = returnUrl });
}
EssentialCSharpWebUser? foundUser = await userManager.FindByEmailAsync(Input.Email);
if (Input.Password is null)
{
return RedirectToPage(Url.Content("~/"), new { ReturnUrl = returnUrl });
}
if (foundUser is not null)
{
result = await signInManager.PasswordSignInAsync(foundUser, Input.Password, Input.RememberMe, lockoutOnFailure: true);
// Call the referral service to get the referral ID and set it onto the user claim
_ = await referralService.EnsureReferralIdAsync(foundUser);
}
else
{
result = await signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: true);
}
if (result.Succeeded)
{
logger.LogInformation("User logged in.");
return LocalRedirect(returnUrl);
}
if (result.RequiresTwoFactor)
{
return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
}
if (result.IsLockedOut)
return Page();
}

if (hCaptcha_response is null)
{
ModelState.AddModelError(CaptchaOptions.HttpPostResponseKeyName, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription);
return Page();
}

HCaptchaResult? response = await captchaService.VerifyAsync(hCaptcha_response);
if (response is null)
{
ModelState.AddModelError(CaptchaOptions.HttpPostResponseKeyName, "Error: HCaptcha API response unexpectedly null");
return Page();
}

if (response.Success)
{
if (ModelState.IsValid)
{
logger.LogWarning("User account locked out.");
return RedirectToPage("./Lockout");
Microsoft.AspNetCore.Identity.SignInResult result;
if (Input.Email is null)
{
return RedirectToPage(Url.Content("~/"), new { ReturnUrl = returnUrl });
}
EssentialCSharpWebUser? foundUser = await userManager.FindByEmailAsync(Input.Email);
if (Input.Password is null)
{
return RedirectToPage(Url.Content("~/"), new { ReturnUrl = returnUrl });
}
if (foundUser is not null)
{
result = await signInManager.PasswordSignInAsync(foundUser, Input.Password, Input.RememberMe, lockoutOnFailure: true);
// Call the referral service to get the referral ID and set it onto the user claim
_ = await referralService.EnsureReferralIdAsync(foundUser);
}
else
{
result = await signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: true);
}
if (result.Succeeded)
{
logger.LogInformation("User logged in.");
return LocalRedirect(returnUrl);
}
if (result.RequiresTwoFactor)
{
return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
}
if (result.IsLockedOut)
{
logger.LogWarning("User account locked out.");
return RedirectToPage("./Lockout");
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return Page();
}
}
else
}
else
{
switch (response.ErrorCodes?.Length)
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return Page();
case 0:
throw new InvalidOperationException("The HCaptcha determined the passcode is not valid, and does not meet the security criteria");
case > 1:
throw new InvalidOperationException("HCaptcha returned error codes: " + string.Join(", ", response.ErrorCodes));
default:
{
if (response.ErrorCodes is null)
{
throw new InvalidOperationException("HCaptcha returned error codes unexpectedly null");
}
if (HCaptchaErrorDetails.TryGetValue(response.ErrorCodes.Single(), out HCaptchaErrorDetails? details))
{
switch (details.ErrorCode)
{
case HCaptchaErrorDetails.MissingInputResponse:
case HCaptchaErrorDetails.InvalidInputResponse:
case HCaptchaErrorDetails.InvalidOrAlreadySeenResponse:
ModelState.AddModelError(string.Empty, details.FriendlyDescription);
logger.LogInformation("HCaptcha returned error code: {ErrorDetails}", details.ToString());
break;
case HCaptchaErrorDetails.BadRequest:
ModelState.AddModelError(string.Empty, details.FriendlyDescription);
logger.LogInformation("HCaptcha returned error code: {ErrorDetails}", details.ToString());
break;
case HCaptchaErrorDetails.MissingInputSecret:
case HCaptchaErrorDetails.InvalidInputSecret:
case HCaptchaErrorDetails.NotUsingDummyPasscode:
case HCaptchaErrorDetails.SitekeySecretMismatch:
logger.LogCritical("HCaptcha returned error code: {ErrorDetails}", details.ToString());
break;
default:
throw new InvalidOperationException("HCaptcha returned unknown error code: " + details?.ErrorCode);
}
}
else
{
throw new InvalidOperationException("HCaptcha returned unknown error code: " + response.ErrorCodes.Single());
}

break;
}

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@
<label asp-for="Input.Email" class="form-label"></label>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<div class="form-group">
<div class="h-captcha" data-sitekey=@Model.CaptchaOptions.SiteKey></div>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Resend</button>
</form>
</div>
</div>

@section Scripts {
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
<partial name="_ValidationScriptsPartial" />
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@
using System.Text;
using System.Text.Encodings.Web;
using EssentialCSharp.Web.Areas.Identity.Data;
using EssentialCSharp.Web.Models;
using EssentialCSharp.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Options;

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

[AllowAnonymous]
public class ResendEmailConfirmationModel(UserManager<EssentialCSharpWebUser> userManager, IEmailSender emailSender) : PageModel
public class ResendEmailConfirmationModel(UserManager<EssentialCSharpWebUser> userManager, IEmailSender emailSender, ICaptchaService captchaService, IOptions<CaptchaOptions> optionsAccessor) : PageModel
{
public CaptchaOptions CaptchaOptions { get; } = optionsAccessor.Value;
private InputModel? _Input;
[BindProperty]
public InputModel Input
Expand All @@ -31,43 +35,108 @@ public class InputModel

public async Task<IActionResult> OnPostAsync()
{
string? hCaptcha_response = Request.Form[CaptchaOptions.HttpPostResponseKeyName];

if (!ModelState.IsValid)
{
return Page();
}

if (Input.Email is null)
if (hCaptcha_response is null)
{
ModelState.AddModelError(string.Empty, "Error: Email is null. Please enter in an email");
ModelState.AddModelError(CaptchaOptions.HttpPostResponseKeyName, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription);
return Page();
}

EssentialCSharpWebUser? user = await userManager.FindByEmailAsync(Input.Email);
if (user is null)
HCaptchaResult? response = await captchaService.VerifyAsync(hCaptcha_response);
if (response is null)
{
return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
ModelState.AddModelError(CaptchaOptions.HttpPostResponseKeyName, "Error: HCaptcha API response unexpectedly null");
return Page();
}

string userId = await userManager.GetUserIdAsync(user);
string code = await userManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
string? callbackUrl = Url.Page(
"/Account/ConfirmEmail",
pageHandler: null,
values: new { userId = userId, code = code },
protocol: Request.Scheme);

if (callbackUrl is null)
if (response.Success)
{
ModelState.AddModelError(string.Empty, "Error: callback url unexpectedly null.");
if (Input.Email is null)
{
ModelState.AddModelError(string.Empty, "Error: Email is null. Please enter in an email");
return Page();
}

EssentialCSharpWebUser? user = await userManager.FindByEmailAsync(Input.Email);
if (user is null)
{
return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
}

string userId = await userManager.GetUserIdAsync(user);
string code = await userManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
string? callbackUrl = Url.Page(
"/Account/ConfirmEmail",
pageHandler: null,
values: new { userId = userId, code = code },
protocol: Request.Scheme);

if (callbackUrl is null)
{
ModelState.AddModelError(string.Empty, "Error: callback url unexpectedly null.");
return Page();
}
await emailSender.SendEmailAsync(
Input.Email,
"Confirm your email",
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");

ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email. If you can't find the email, please check your spam folder.");
return Page();
}
await emailSender.SendEmailAsync(
Input.Email,
"Confirm your email",
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
else
{
switch (response.ErrorCodes?.Length)
{
case 0:
throw new InvalidOperationException("The HCaptcha determined the passcode is not valid, and does not meet the security criteria");
case > 1:
throw new InvalidOperationException("HCaptcha returned error codes: " + string.Join(", ", response.ErrorCodes));
default:
{
if (response.ErrorCodes is null)
{
throw new InvalidOperationException("HCaptcha returned error codes unexpectedly null");
}
if (HCaptchaErrorDetails.TryGetValue(response.ErrorCodes.Single(), out HCaptchaErrorDetails? details))
{
switch (details.ErrorCode)
{
case HCaptchaErrorDetails.MissingInputResponse:
case HCaptchaErrorDetails.InvalidInputResponse:
case HCaptchaErrorDetails.InvalidOrAlreadySeenResponse:
ModelState.AddModelError(string.Empty, details.FriendlyDescription);
break;
case HCaptchaErrorDetails.BadRequest:
ModelState.AddModelError(string.Empty, details.FriendlyDescription);
break;
case HCaptchaErrorDetails.MissingInputSecret:
case HCaptchaErrorDetails.InvalidInputSecret:
case HCaptchaErrorDetails.NotUsingDummyPasscode:
case HCaptchaErrorDetails.SitekeySecretMismatch:
break;
default:
throw new InvalidOperationException("HCaptcha returned unknown error code: " + details?.ErrorCode);
}
}
else
{
throw new InvalidOperationException("HCaptcha returned unknown error code: " + response.ErrorCodes.Single());
}

break;
}

}
}

ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email. If you can't find the email, please check your spam folder.");
return Page();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,15 @@
<label asp-for="Input.ConfirmPassword" class="form-label"></label>
<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
</div>
<div class="form-group">
<div class="h-captcha" data-sitekey=@Model.CaptchaOptions.SiteKey></div>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset</button>
</form>
</div>
</div>

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