diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml index ddb1f9a2..9863af53 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml @@ -29,6 +29,9 @@ @Html.DisplayNameFor(m => m.Input.RememberMe) +
+
+
@@ -82,5 +85,6 @@ @section Scripts { + } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs index a3ea80a7..5bb5f29f 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -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 signInManager, UserManager userManager, ILogger logger, IReferralService referralService) : PageModel +public class LoginModel(SignInManager signInManager, UserManager userManager, ILogger logger, IReferralService referralService, ICaptchaService captchaService, IOptions optionsAccessor) : PageModel { + public CaptchaOptions CaptchaOptions { get; } = optionsAccessor.Value; private InputModel? _Input; [BindProperty] public InputModel Input @@ -60,49 +64,119 @@ public async Task OnGetAsync(string? returnUrl = null) public async Task 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; + } + } } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml index d457b6b8..ce2c304f 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml @@ -16,11 +16,15 @@ +
+
+
@section Scripts { + } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs index 8ef99283..15c4400e 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs @@ -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 userManager, IEmailSender emailSender) : PageModel +public class ResendEmailConfirmationModel(UserManager userManager, IEmailSender emailSender, ICaptchaService captchaService, IOptions optionsAccessor) : PageModel { + public CaptchaOptions CaptchaOptions { get; } = optionsAccessor.Value; private InputModel? _Input; [BindProperty] public InputModel Input @@ -31,43 +35,108 @@ public class InputModel public async Task 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 clicking here."); + + 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 clicking here."); + 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(); } } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml index e430d01e..23749f1d 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml @@ -27,11 +27,15 @@ +
+
+
@section Scripts { + } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs index 383de416..dcc1e30a 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs @@ -1,15 +1,19 @@ using System.ComponentModel.DataAnnotations; using System.Text; using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Models; +using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; namespace EssentialCSharp.Web.Areas.Identity.Pages.Account; -public class ResetPasswordModel(UserManager userManager) : PageModel +public class ResetPasswordModel(UserManager userManager, ICaptchaService captchaService, IOptions optionsAccessor) : PageModel { + public CaptchaOptions CaptchaOptions { get; } = optionsAccessor.Value; private InputModel? _Input; [BindProperty] public InputModel Input @@ -57,44 +61,109 @@ public IActionResult OnGet(string? code = null) public async Task 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 required."); - return RedirectToPage(); + 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) { - // Don't reveal that the user does not exist - return RedirectToPage("./ResetPasswordConfirmation"); + ModelState.AddModelError(CaptchaOptions.HttpPostResponseKeyName, "Error: HCaptcha API response unexpectedly null"); + return Page(); } - if (Input.Password is null) + if (response.Success) { - ModelState.AddModelError(string.Empty, "Error: Password is required."); - return RedirectToPage(); + if (Input.Email is null) + { + ModelState.AddModelError(string.Empty, "Error: Email is required."); + return RedirectToPage(); + } + EssentialCSharpWebUser? user = await userManager.FindByEmailAsync(Input.Email); + if (user is null) + { + // Don't reveal that the user does not exist + return RedirectToPage("./ResetPasswordConfirmation"); + } + + if (Input.Password is null) + { + ModelState.AddModelError(string.Empty, "Error: Password is required."); + return RedirectToPage(); + } + if (Input.Code is null) + { + ModelState.AddModelError(string.Empty, "Error: Code is required."); + return RedirectToPage(); + } + + IdentityResult result = await userManager.ResetPasswordAsync(user, Input.Code, Input.Password); + if (result.Succeeded) + { + return RedirectToPage("./ResetPasswordConfirmation"); + } + + foreach (IdentityError error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + return Page(); } - if (Input.Code is null) + else { - ModelState.AddModelError(string.Empty, "Error: Code is required."); - return RedirectToPage(); - } + 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()); + } - IdentityResult result = await userManager.ResetPasswordAsync(user, Input.Code, Input.Password); - if (result.Succeeded) - { - return RedirectToPage("./ResetPasswordConfirmation"); - } + break; + } - foreach (IdentityError error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); + } } + return Page(); } }