From d2e48a5c2c9712fefa81e5e521b0c6551df24882 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Wed, 16 Jun 2021 12:47:41 -0400 Subject: [PATCH] hcaptcha validation on password login (#1398) --- src/Core/Context/CurrentContext.cs | 55 ++++++++++---- src/Core/Context/ICurrentContext.cs | 5 +- .../ResourceOwnerPasswordValidator.cs | 41 ++++++++--- .../Services/ICaptchaValidationService.cs | 10 +++ .../HCaptchaValidationService.cs | 72 +++++++++++++++++++ .../NoopCaptchaValidationService.cs | 14 ++++ src/Core/Settings/GlobalSettings.cs | 7 ++ .../Utilities/ServiceCollectionExtensions.cs | 10 +++ 8 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 src/Core/Services/ICaptchaValidationService.cs create mode 100644 src/Core/Services/Implementations/HCaptchaValidationService.cs create mode 100644 src/Core/Services/NoopImplementations/NoopCaptchaValidationService.cs diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index b9804a13a8..2e27aaf920 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -27,6 +27,10 @@ namespace Bit.Core.Context public virtual List Organizations { get; set; } public virtual Guid? InstallationId { get; set; } public virtual Guid? OrganizationId { get; set; } + public virtual bool CloudflareWorkerProxied { get; set; } + public virtual bool IsBot { get; set; } + public virtual bool MaybeBot { get; set; } + public virtual int? BotScore { get; set; } public async virtual Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings) { @@ -49,6 +53,27 @@ namespace Bit.Core.Context { DeviceType = dType; } + + if (!BotScore.HasValue && httpContext.Request.Headers.ContainsKey("X-Cf-Bot-Score") && + int.TryParse(httpContext.Request.Headers["X-Cf-Bot-Score"], out var parsedBotScore)) + { + BotScore = parsedBotScore; + } + + if (httpContext.Request.Headers.ContainsKey("X-Cf-Worked-Proxied")) + { + CloudflareWorkerProxied = httpContext.Request.Headers["X-Cf-Worked-Proxied"] == "1"; + } + + if (httpContext.Request.Headers.ContainsKey("X-Cf-Is-Bot")) + { + IsBot = httpContext.Request.Headers["X-Cf-Is-Bot"] == "1"; + } + + if (httpContext.Request.Headers.ContainsKey("X-Cf-Maybe-Bot")) + { + MaybeBot = httpContext.Request.Headers["X-Cf-Maybe-Bot"] == "1"; + } } public async virtual Task BuildAsync(ClaimsPrincipal user, GlobalSettings globalSettings) @@ -192,70 +217,70 @@ namespace Bit.Core.Context { return Organizations?.Any(o => o.Id == orgId && o.Type == OrganizationUserType.Custom) ?? false; } - + public bool AccessBusinessPortal(Guid orgId) { - return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId + return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId && (o.Permissions?.AccessBusinessPortal ?? false)) ?? false); } public bool AccessEventLogs(Guid orgId) { - return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId + return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId && (o.Permissions?.AccessEventLogs ?? false)) ?? false); } public bool AccessImportExport(Guid orgId) { - return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId + return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId && (o.Permissions?.AccessImportExport ?? false)) ?? false); } public bool AccessReports(Guid orgId) { - return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId + return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId && (o.Permissions?.AccessReports ?? false)) ?? false); } public bool ManageAllCollections(Guid orgId) { - return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId + return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId && (o.Permissions?.ManageAllCollections ?? false)) ?? false); } public bool ManageAssignedCollections(Guid orgId) { - return OrganizationManager(orgId) || (Organizations?.Any(o => o.Id == orgId + return OrganizationManager(orgId) || (Organizations?.Any(o => o.Id == orgId && (o.Permissions?.ManageAssignedCollections ?? false)) ?? false); } public bool ManageGroups(Guid orgId) { - return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId + return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId && (o.Permissions?.ManageGroups ?? false)) ?? false); } public bool ManagePolicies(Guid orgId) { - return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId + return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId && (o.Permissions?.ManagePolicies ?? false)) ?? false); } public bool ManageSso(Guid orgId) { - return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId + return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId && (o.Permissions?.ManageSso ?? false)) ?? false); } public bool ManageUsers(Guid orgId) { - return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId + return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId && (o.Permissions?.ManageUsers ?? false)) ?? false); } - + public bool ManageResetPassword(Guid orgId) { - return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId + return OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId && (o.Permissions?.ManageResetPassword ?? false)) ?? false); } @@ -283,9 +308,9 @@ namespace Bit.Core.Context private Permissions SetOrganizationPermissionsFromClaims(string organizationId, Dictionary> claimsDict) { - bool hasClaim(string claimKey) + bool hasClaim(string claimKey) { - return claimsDict.ContainsKey(claimKey) ? + return claimsDict.ContainsKey(claimKey) ? claimsDict[claimKey].Any(x => x.Value == organizationId) : false; } diff --git a/src/Core/Context/ICurrentContext.cs b/src/Core/Context/ICurrentContext.cs index ad56476863..d6eb8686a4 100644 --- a/src/Core/Context/ICurrentContext.cs +++ b/src/Core/Context/ICurrentContext.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Security.Claims; using System.Threading.Tasks; @@ -21,6 +21,9 @@ namespace Bit.Core.Context List Organizations { get; set; } Guid? InstallationId { get; set; } Guid? OrganizationId { get; set; } + bool IsBot { get; set; } + bool MaybeBot { get; set; } + int? BotScore { get; set; } Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings); Task BuildAsync(ClaimsPrincipal user, GlobalSettings globalSettings); diff --git a/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs b/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs index c77f6f2ab3..358247ecc4 100644 --- a/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs +++ b/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs @@ -20,6 +20,7 @@ namespace Bit.Core.IdentityServer private UserManager _userManager; private readonly IUserService _userService; private readonly ICurrentContext _currentContext; + private readonly ICaptchaValidationService _captchaValidationService; public ResourceOwnerPasswordValidator( UserManager userManager, @@ -35,7 +36,8 @@ namespace Bit.Core.IdentityServer ILogger logger, ICurrentContext currentContext, GlobalSettings globalSettings, - IPolicyRepository policyRepository) + IPolicyRepository policyRepository, + ICaptchaValidationService captchaValidationService) : base(userManager, deviceRepository, deviceService, userService, eventService, organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, applicationCacheService, mailService, logger, currentContext, globalSettings, policyRepository) @@ -43,10 +45,39 @@ namespace Bit.Core.IdentityServer _userManager = userManager; _userService = userService; _currentContext = currentContext; + _captchaValidationService = captchaValidationService; } public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context) { + // Uncomment whenever we want to require the `auth-email` header + // + //if (!_currentContext.HttpContext.Request.Headers.ContainsKey("Auth-Email") || + // _currentContext.HttpContext.Request.Headers["Auth-Email"] != context.UserName) + //{ + // context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, + // "Auth-Email header invalid."); + // return; + //} + + if (_captchaValidationService.ServiceEnabled && _currentContext.IsBot) + { + var captchaResponse = context.Request.Raw["CaptchaResponse"]?.ToString(); + if (string.IsNullOrWhiteSpace(captchaResponse)) + { + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Captcha required."); + return; + } + + var captchaValid = await _captchaValidationService.ValidateCaptchaResponseAsync(captchaResponse, + _currentContext.IpAddress); + if (!captchaValid) + { + await BuildErrorResultAsync("Captcha is invalid.", false, context, null); + return; + } + } + await ValidateAsync(context, context.Request); } @@ -57,14 +88,6 @@ namespace Bit.Core.IdentityServer return (null, false); } - // Uncomment whenever we want to require the `auth-email` header - // - //if (!_currentContext.HttpContext.Request.Headers.ContainsKey("Auth-Email") || - // _currentContext.HttpContext.Request.Headers["Auth-Email"] != context.UserName) - //{ - // return (null, false); - //} - var user = await _userManager.FindByEmailAsync(context.UserName.ToLowerInvariant()); if (user == null || !await _userService.CheckPasswordAsync(user, context.Password)) { diff --git a/src/Core/Services/ICaptchaValidationService.cs b/src/Core/Services/ICaptchaValidationService.cs new file mode 100644 index 0000000000..7fc264f77f --- /dev/null +++ b/src/Core/Services/ICaptchaValidationService.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; + +namespace Bit.Core.Services +{ + public interface ICaptchaValidationService + { + bool ServiceEnabled { get; } + Task ValidateCaptchaResponseAsync(string captchResponse, string clientIpAddress); + } +} diff --git a/src/Core/Services/Implementations/HCaptchaValidationService.cs b/src/Core/Services/Implementations/HCaptchaValidationService.cs new file mode 100644 index 0000000000..947c6b6303 --- /dev/null +++ b/src/Core/Services/Implementations/HCaptchaValidationService.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace Bit.Core.Services +{ + public class HCaptchaValidationService : ICaptchaValidationService + { + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly GlobalSettings _globalSettings; + + public HCaptchaValidationService( + ILogger logger, + IHttpClientFactory httpClientFactory, + GlobalSettings globalSettings) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _globalSettings = globalSettings; + } + + public bool ServiceEnabled => true; + + public async Task ValidateCaptchaResponseAsync(string captchResponse, string clientIpAddress) + { + if (string.IsNullOrWhiteSpace(captchResponse)) + { + return false; + } + + var httpClient = _httpClientFactory.CreateClient("HCaptchaValidationService"); + + var requestMessage = new HttpRequestMessage + { + Method = HttpMethod.Post, + RequestUri = new Uri("https://hcaptcha.com/siteverify"), + Content = new FormUrlEncodedContent(new Dictionary + { + { "response", captchResponse.TrimStart("hcaptcha|".ToCharArray()) }, + { "secret", _globalSettings.Captcha.HCaptchaSecretKey }, + { "sitekey", _globalSettings.Captcha.HCaptchaSiteKey }, + { "remoteip", clientIpAddress } + }) + }; + + HttpResponseMessage responseMessage; + try + { + responseMessage = await httpClient.SendAsync(requestMessage); + } + catch (Exception e) + { + _logger.LogError(11389, e, "Unable to verify with HCaptcha."); + return false; + } + + if (!responseMessage.IsSuccessStatusCode) + { + return false; + } + + var responseContent = await responseMessage.Content.ReadAsStringAsync(); + dynamic jsonResponse = JsonConvert.DeserializeObject(responseContent); + return (bool)jsonResponse.success; + } + } +} diff --git a/src/Core/Services/NoopImplementations/NoopCaptchaValidationService.cs b/src/Core/Services/NoopImplementations/NoopCaptchaValidationService.cs new file mode 100644 index 0000000000..203948794f --- /dev/null +++ b/src/Core/Services/NoopImplementations/NoopCaptchaValidationService.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; + +namespace Bit.Core.Services +{ + public class NoopCaptchaValidationService : ICaptchaValidationService + { + public bool ServiceEnabled => false; + + public Task ValidateCaptchaResponseAsync(string captchResponse, string clientIpAddress) + { + return Task.FromResult(true); + } + } +} diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index ece38a05cc..717cc1fdb3 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -40,6 +40,7 @@ namespace Bit.Core.Settings public virtual bool DisableEmailNewDevice { get; set; } public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days public virtual string EventGridKey { get; set; } + public virtual CaptchaSettings Captcha { get; set; } = new CaptchaSettings(); public virtual InstallationSettings Installation { get; set; } = new InstallationSettings(); public virtual BaseServiceUriSettings BaseServiceUri { get; set; } public virtual SqlSettings SqlServer { get; set; } = new SqlSettings(); @@ -466,5 +467,11 @@ namespace Bit.Core.Settings { public int CacheLifetimeInSeconds { get; set; } = 60; } + + public class CaptchaSettings + { + public string HCaptchaSecretKey { get; set; } + public string HCaptchaSiteKey { get; set; } + } } } diff --git a/src/Core/Utilities/ServiceCollectionExtensions.cs b/src/Core/Utilities/ServiceCollectionExtensions.cs index 606caf9797..4c22b2bc11 100644 --- a/src/Core/Utilities/ServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ServiceCollectionExtensions.cs @@ -253,6 +253,16 @@ namespace Bit.Core.Utilities { services.AddSingleton(); } + + if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Captcha?.HCaptchaSecretKey) && + CoreHelpers.SettingHasValue(globalSettings.Captcha?.HCaptchaSiteKey)) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } } public static void AddNoopServices(this IServiceCollection services)