diff --git a/src/Api/Controllers/AccountsController.cs b/src/Api/Controllers/AccountsController.cs index b3668e299..3f9cd3591 100644 --- a/src/Api/Controllers/AccountsController.cs +++ b/src/Api/Controllers/AccountsController.cs @@ -83,6 +83,7 @@ namespace Bit.Api.Controllers [HttpPost("register")] [AllowAnonymous] + [CaptchaProtected] public async Task PostRegister([FromBody]RegisterRequestModel model) { var result = await _userService.RegisterUserAsync(model.ToUser(), model.MasterPasswordHash, diff --git a/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs b/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs index 1b6b427f7..a42acd676 100644 --- a/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs +++ b/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs @@ -60,7 +60,7 @@ namespace Bit.Core.IdentityServer //} string bypassToken = null; - if (_captchaValidationService.ServiceEnabled && (_currentContext.IsBot || _captchaValidationService.RequireCaptcha)) + if (_captchaValidationService.RequireCaptchaValidation(_currentContext)) { var user = await _userManager.FindByEmailAsync(context.UserName.ToLowerInvariant()); var captchaResponse = context.Request.Raw["captchaResponse"]?.ToString(); @@ -69,7 +69,7 @@ namespace Bit.Core.IdentityServer { context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Captcha required.", new Dictionary { - { "HCaptcha_SiteKey", _captchaValidationService.SiteKey }, + { _captchaValidationService.SiteKeyResponseKeyName, _captchaValidationService.SiteKey }, }); return; } diff --git a/src/Core/Models/Api/Request/Accounts/RegisterRequestModel.cs b/src/Core/Models/Api/Request/Accounts/RegisterRequestModel.cs index 6e7fda791..727713005 100644 --- a/src/Core/Models/Api/Request/Accounts/RegisterRequestModel.cs +++ b/src/Core/Models/Api/Request/Accounts/RegisterRequestModel.cs @@ -9,7 +9,7 @@ using Newtonsoft.Json; namespace Bit.Core.Models.Api { - public class RegisterRequestModel : IValidatableObject + public class RegisterRequestModel : IValidatableObject, ICaptchaProtectedModel { [StringLength(50)] public string Name { get; set; } @@ -22,6 +22,7 @@ namespace Bit.Core.Models.Api public string MasterPasswordHash { get; set; } [StringLength(50)] public string MasterPasswordHint { get; set; } + public string CaptchaResponse { get; set; } public string Key { get; set; } public KeysRequestModel Keys { get; set; } public string Token { get; set; } diff --git a/src/Core/Models/Api/Request/ICaptchaProtectedModel.cs b/src/Core/Models/Api/Request/ICaptchaProtectedModel.cs new file mode 100644 index 000000000..939c1448b --- /dev/null +++ b/src/Core/Models/Api/Request/ICaptchaProtectedModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Models.Api +{ + public interface ICaptchaProtectedModel + { + string CaptchaResponse { get; set; } + } +} diff --git a/src/Core/Services/ICaptchaValidationService.cs b/src/Core/Services/ICaptchaValidationService.cs index c38aaca00..08a18b0c8 100644 --- a/src/Core/Services/ICaptchaValidationService.cs +++ b/src/Core/Services/ICaptchaValidationService.cs @@ -1,13 +1,14 @@ using System.Threading.Tasks; +using Bit.Core.Context; using Bit.Core.Models.Table; namespace Bit.Core.Services { public interface ICaptchaValidationService { - bool ServiceEnabled { get; } string SiteKey { get; } - bool RequireCaptcha { get; } + string SiteKeyResponseKeyName { get; } + bool RequireCaptchaValidation(ICurrentContext currentContext); Task ValidateCaptchaResponseAsync(string captchResponse, string clientIpAddress); string GenerateCaptchaBypassToken(User user); bool ValidateCaptchaBypassToken(string encryptedToken, User user); diff --git a/src/Core/Services/Implementations/HCaptchaValidationService.cs b/src/Core/Services/Implementations/HCaptchaValidationService.cs index 744c554e7..e1f507f7b 100644 --- a/src/Core/Services/Implementations/HCaptchaValidationService.cs +++ b/src/Core/Services/Implementations/HCaptchaValidationService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; +using Bit.Core.Context; using Bit.Core.Models.Table; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -33,9 +34,8 @@ namespace Bit.Core.Services _dataProtector = dataProtectorProvider.CreateProtector("CaptchaServiceDataProtector"); } - public bool ServiceEnabled => true; + public string SiteKeyResponseKeyName => "HCaptcha_SiteKey"; public string SiteKey => _globalSettings.Captcha.HCaptchaSiteKey; - public bool RequireCaptcha => _globalSettings.Captcha.RequireCaptcha; public string GenerateCaptchaBypassToken(User user) => $"{TokenClearTextPrefix}{_dataProtector.Protect(CaptchaBypassTokenContent(user))}"; @@ -44,9 +44,9 @@ namespace Bit.Core.Services CoreHelpers.TokenIsValid(TokenName, _dataProtector, encryptedToken[TokenClearTextPrefix.Length..], user.Email, user.Id, TokenLifetimeInHours); - public async Task ValidateCaptchaResponseAsync(string captchResponse, string clientIpAddress) + public async Task ValidateCaptchaResponseAsync(string captchaResponse, string clientIpAddress) { - if (string.IsNullOrWhiteSpace(captchResponse)) + if (string.IsNullOrWhiteSpace(captchaResponse)) { return false; } @@ -59,7 +59,7 @@ namespace Bit.Core.Services RequestUri = new Uri("https://hcaptcha.com/siteverify"), Content = new FormUrlEncodedContent(new Dictionary { - { "response", captchResponse.TrimStart("hcaptcha|".ToCharArray()) }, + { "response", captchaResponse.TrimStart("hcaptcha|".ToCharArray()) }, { "secret", _globalSettings.Captcha.HCaptchaSecretKey }, { "sitekey", SiteKey }, { "remoteip", clientIpAddress } @@ -87,6 +87,9 @@ namespace Bit.Core.Services return (bool)jsonResponse.success; } + public bool RequireCaptchaValidation(ICurrentContext currentContext) => + currentContext.IsBot || _globalSettings.Captcha.ForceCaptchaRequired; + private static string CaptchaBypassTokenContent(User user) => string.Join(' ', new object[] { TokenName, diff --git a/src/Core/Services/NoopImplementations/NoopCaptchaValidationService.cs b/src/Core/Services/NoopImplementations/NoopCaptchaValidationService.cs index 9f46c2be3..6e16000da 100644 --- a/src/Core/Services/NoopImplementations/NoopCaptchaValidationService.cs +++ b/src/Core/Services/NoopImplementations/NoopCaptchaValidationService.cs @@ -1,13 +1,14 @@ using System.Threading.Tasks; +using Bit.Core.Context; using Bit.Core.Models.Table; namespace Bit.Core.Services { public class NoopCaptchaValidationService : ICaptchaValidationService { - public bool ServiceEnabled => false; + public string SiteKeyResponseKeyName => null; public string SiteKey => null; - public bool RequireCaptcha => false; + public bool RequireCaptchaValidation(ICurrentContext currentContext) => false; public string GenerateCaptchaBypassToken(User user) => ""; public bool ValidateCaptchaBypassToken(string encryptedToken, User user) => false; diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index e8db18888..db0a445cc 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -473,7 +473,7 @@ namespace Bit.Core.Settings public class CaptchaSettings { - public bool RequireCaptcha { get; set; } = false; + public bool ForceCaptchaRequired { get; set; } = false; public string HCaptchaSecretKey { get; set; } public string HCaptchaSiteKey { get; set; } } diff --git a/src/Core/Utilities/CaptchaProtectedAttribute.cs b/src/Core/Utilities/CaptchaProtectedAttribute.cs new file mode 100644 index 000000000..02d644e53 --- /dev/null +++ b/src/Core/Utilities/CaptchaProtectedAttribute.cs @@ -0,0 +1,37 @@ +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Models.Api; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Utilities +{ + public class CaptchaProtectedAttribute : ActionFilterAttribute + { + public string ModelParameterName { get; set; } = "model"; + + public override void OnActionExecuting(ActionExecutingContext context) + { + var currentContext = context.HttpContext.RequestServices.GetRequiredService(); + var captchaValidationService = context.HttpContext.RequestServices.GetRequiredService(); + + if (captchaValidationService.RequireCaptchaValidation(currentContext)) + { + var captchaResponse = (context.ActionArguments[ModelParameterName] as ICaptchaProtectedModel)?.CaptchaResponse; + + if (string.IsNullOrWhiteSpace(captchaResponse)) + { + throw new BadRequestException(captchaValidationService.SiteKeyResponseKeyName, captchaValidationService.SiteKey); + } + + var captchaValid = captchaValidationService.ValidateCaptchaResponseAsync(captchaResponse, + currentContext.IpAddress).GetAwaiter().GetResult(); + if (!captchaValid) + { + throw new BadRequestException("Captcha is invalid. Please refresh and try again"); + } + } + } + } +}