From d2e48a5c2c9712fefa81e5e521b0c6551df24882 Mon Sep 17 00:00:00 2001
From: Kyle Spearrin <kspearrin@users.noreply.github.com>
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<CurrentContentOrganization> 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<string, IEnumerable<Claim>> 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<CurrentContentOrganization> 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<User> _userManager;
         private readonly IUserService _userService;
         private readonly ICurrentContext _currentContext;
+        private readonly ICaptchaValidationService _captchaValidationService;
 
         public ResourceOwnerPasswordValidator(
             UserManager<User> userManager,
@@ -35,7 +36,8 @@ namespace Bit.Core.IdentityServer
             ILogger<ResourceOwnerPasswordValidator> 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<bool> 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<HCaptchaValidationService> _logger;
+        private readonly IHttpClientFactory _httpClientFactory;
+        private readonly GlobalSettings _globalSettings;
+
+        public HCaptchaValidationService(
+            ILogger<HCaptchaValidationService> logger,
+            IHttpClientFactory httpClientFactory,
+            GlobalSettings globalSettings)
+        {
+            _logger = logger;
+            _httpClientFactory = httpClientFactory;
+            _globalSettings = globalSettings;
+        }
+
+        public bool ServiceEnabled => true;
+
+        public async Task<bool> 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<string, string>
+                {
+                    { "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<bool> 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<IReferenceEventService, AzureQueueReferenceEventService>();
             }
+
+            if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Captcha?.HCaptchaSecretKey) &&
+                CoreHelpers.SettingHasValue(globalSettings.Captcha?.HCaptchaSiteKey))
+            {
+                services.AddSingleton<ICaptchaValidationService, HCaptchaValidationService>();
+            }
+            else
+            {
+                services.AddSingleton<ICaptchaValidationService, NoopCaptchaValidationService>();
+            }
         }
 
         public static void AddNoopServices(this IServiceCollection services)