From 8e1e2fa2fe16fbab0a90c5ff12942c372d4296a3 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Wed, 21 Jul 2021 13:42:06 -0500 Subject: [PATCH] Feature/sync Enable hcaptcha on login (#1469) * Share globalSettings hcaptcha public key with clients * Require captcha valid only prior to two factor users with two factor will have already solved captcha is necessary. Users without two factor will have`TwoFactorVerified` set to false * Do not require CaptchaResponse on two-factor requests * Add option to always require captcha for testing purposes * Allow for self-hosted instances if they want to use it * Move refresh suggestion to correct error * Expect lifetime in helper method * Add captcha bypass token to successful captcha validations * Remove twofactorValidated * PR Feedback --- .../src/CommCore/Services/ProviderService.cs | 6 ++-- .../ResourceOwnerPasswordValidator.cs | 24 +++++++++++----- .../Services/ICaptchaValidationService.cs | 5 ++++ .../Implementations/EmergencyAccessService.cs | 3 +- .../HCaptchaValidationService.cs | 28 ++++++++++++++++++- .../NoopCaptchaValidationService.cs | 6 ++++ src/Core/Settings/GlobalSettings.cs | 1 + src/Core/Utilities/CoreHelpers.cs | 7 +++-- .../Utilities/ServiceCollectionExtensions.cs | 2 +- 9 files changed, 67 insertions(+), 15 deletions(-) diff --git a/bitwarden_license/src/CommCore/Services/ProviderService.cs b/bitwarden_license/src/CommCore/Services/ProviderService.cs index f36052b3b3..615f32bfc0 100644 --- a/bitwarden_license/src/CommCore/Services/ProviderService.cs +++ b/bitwarden_license/src/CommCore/Services/ProviderService.cs @@ -88,7 +88,8 @@ namespace Bit.CommCore.Services throw new BadRequestException("Provider is already setup."); } - if (!CoreHelpers.TokenIsValid("ProviderSetupInvite", _dataProtector, token, owner.Email, provider.Id, _globalSettings)) + if (!CoreHelpers.TokenIsValid("ProviderSetupInvite", _dataProtector, token, owner.Email, provider.Id, + _globalSettings.OrganizationInviteExpirationHours)) { throw new BadRequestException("Invalid token."); } @@ -196,7 +197,8 @@ namespace Bit.CommCore.Services throw new BadRequestException("Already accepted."); } - if (!CoreHelpers.TokenIsValid("ProviderUserInvite", _dataProtector, token, user.Email, providerUser.Id, _globalSettings)) + if (!CoreHelpers.TokenIsValid("ProviderUserInvite", _dataProtector, token, user.Email, providerUser.Id, + _globalSettings.OrganizationInviteExpirationHours)) { throw new BadRequestException("Invalid token."); } diff --git a/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs b/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs index 358247ecc4..1b6b427f72 100644 --- a/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs +++ b/src/Core/IdentityServer/ResourceOwnerPasswordValidator.cs @@ -21,7 +21,6 @@ namespace Bit.Core.IdentityServer private readonly IUserService _userService; private readonly ICurrentContext _currentContext; private readonly ICaptchaValidationService _captchaValidationService; - public ResourceOwnerPasswordValidator( UserManager userManager, IDeviceRepository deviceRepository, @@ -60,25 +59,36 @@ namespace Bit.Core.IdentityServer // return; //} - if (_captchaValidationService.ServiceEnabled && _currentContext.IsBot) + string bypassToken = null; + if (_captchaValidationService.ServiceEnabled && (_currentContext.IsBot || _captchaValidationService.RequireCaptcha)) { - var captchaResponse = context.Request.Raw["CaptchaResponse"]?.ToString(); + var user = await _userManager.FindByEmailAsync(context.UserName.ToLowerInvariant()); + var captchaResponse = context.Request.Raw["captchaResponse"]?.ToString(); + if (string.IsNullOrWhiteSpace(captchaResponse)) { - context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Captcha required."); + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Captcha required.", + new Dictionary { + { "HCaptcha_SiteKey", _captchaValidationService.SiteKey }, + }); return; } - var captchaValid = await _captchaValidationService.ValidateCaptchaResponseAsync(captchaResponse, - _currentContext.IpAddress); + var captchaValid = _captchaValidationService.ValidateCaptchaBypassToken(captchaResponse, user) || + await _captchaValidationService.ValidateCaptchaResponseAsync(captchaResponse, _currentContext.IpAddress); if (!captchaValid) { - await BuildErrorResultAsync("Captcha is invalid.", false, context, null); + await BuildErrorResultAsync("Captcha is invalid. Please refresh and try again", false, context, null); return; } + bypassToken = _captchaValidationService.GenerateCaptchaBypassToken(user); } await ValidateAsync(context, context.Request); + if (context.Result.CustomResponse != null && bypassToken != null) + { + context.Result.CustomResponse["CaptchaBypassToken"] = bypassToken; + } } protected async override Task<(User, bool)> ValidateContextAsync(ResourceOwnerPasswordValidationContext context) diff --git a/src/Core/Services/ICaptchaValidationService.cs b/src/Core/Services/ICaptchaValidationService.cs index 7fc264f77f..c38aaca009 100644 --- a/src/Core/Services/ICaptchaValidationService.cs +++ b/src/Core/Services/ICaptchaValidationService.cs @@ -1,10 +1,15 @@ using System.Threading.Tasks; +using Bit.Core.Models.Table; namespace Bit.Core.Services { public interface ICaptchaValidationService { bool ServiceEnabled { get; } + string SiteKey { get; } + bool RequireCaptcha { get; } Task ValidateCaptchaResponseAsync(string captchResponse, string clientIpAddress); + string GenerateCaptchaBypassToken(User user); + bool ValidateCaptchaBypassToken(string encryptedToken, User user); } } diff --git a/src/Core/Services/Implementations/EmergencyAccessService.cs b/src/Core/Services/Implementations/EmergencyAccessService.cs index 80c2f99ef6..ca88348afe 100644 --- a/src/Core/Services/Implementations/EmergencyAccessService.cs +++ b/src/Core/Services/Implementations/EmergencyAccessService.cs @@ -115,7 +115,8 @@ namespace Bit.Core.Services throw new BadRequestException("Emergency Access not valid."); } - if (!CoreHelpers.TokenIsValid("EmergencyAccessInvite", _dataProtector, token, user.Email, emergencyAccessId, _globalSettings)) + if (!CoreHelpers.TokenIsValid("EmergencyAccessInvite", _dataProtector, token, user.Email, emergencyAccessId, + _globalSettings.OrganizationInviteExpirationHours)) { throw new BadRequestException("Invalid token."); } diff --git a/src/Core/Services/Implementations/HCaptchaValidationService.cs b/src/Core/Services/Implementations/HCaptchaValidationService.cs index 947c6b6303..744c554e70 100644 --- a/src/Core/Services/Implementations/HCaptchaValidationService.cs +++ b/src/Core/Services/Implementations/HCaptchaValidationService.cs @@ -2,7 +2,10 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; +using Bit.Core.Models.Table; using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -10,21 +13,36 @@ namespace Bit.Core.Services { public class HCaptchaValidationService : ICaptchaValidationService { + private const double TokenLifetimeInHours = (double)5 / 60; // 5 minutes + private const string TokenName = "CaptchaBypassToken"; + private const string TokenClearTextPrefix = "BWCaptchaBypass_"; private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly GlobalSettings _globalSettings; + private readonly IDataProtector _dataProtector; public HCaptchaValidationService( ILogger logger, IHttpClientFactory httpClientFactory, + IDataProtectionProvider dataProtectorProvider, GlobalSettings globalSettings) { _logger = logger; _httpClientFactory = httpClientFactory; _globalSettings = globalSettings; + _dataProtector = dataProtectorProvider.CreateProtector("CaptchaServiceDataProtector"); } public bool ServiceEnabled => true; + public string SiteKey => _globalSettings.Captcha.HCaptchaSiteKey; + public bool RequireCaptcha => _globalSettings.Captcha.RequireCaptcha; + + public string GenerateCaptchaBypassToken(User user) => + $"{TokenClearTextPrefix}{_dataProtector.Protect(CaptchaBypassTokenContent(user))}"; + public bool ValidateCaptchaBypassToken(string encryptedToken, User user) => + encryptedToken.StartsWith(TokenClearTextPrefix) && user != null && + CoreHelpers.TokenIsValid(TokenName, _dataProtector, encryptedToken[TokenClearTextPrefix.Length..], + user.Email, user.Id, TokenLifetimeInHours); public async Task ValidateCaptchaResponseAsync(string captchResponse, string clientIpAddress) { @@ -43,7 +61,7 @@ namespace Bit.Core.Services { { "response", captchResponse.TrimStart("hcaptcha|".ToCharArray()) }, { "secret", _globalSettings.Captcha.HCaptchaSecretKey }, - { "sitekey", _globalSettings.Captcha.HCaptchaSiteKey }, + { "sitekey", SiteKey }, { "remoteip", clientIpAddress } }) }; @@ -68,5 +86,13 @@ namespace Bit.Core.Services dynamic jsonResponse = JsonConvert.DeserializeObject(responseContent); return (bool)jsonResponse.success; } + + private static string CaptchaBypassTokenContent(User user) => + string.Join(' ', new object[] { + TokenName, + user?.Id, + user?.Email, + CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow.AddHours(TokenLifetimeInHours)) + }); } } diff --git a/src/Core/Services/NoopImplementations/NoopCaptchaValidationService.cs b/src/Core/Services/NoopImplementations/NoopCaptchaValidationService.cs index 203948794f..9f46c2be3f 100644 --- a/src/Core/Services/NoopImplementations/NoopCaptchaValidationService.cs +++ b/src/Core/Services/NoopImplementations/NoopCaptchaValidationService.cs @@ -1,10 +1,16 @@ using System.Threading.Tasks; +using Bit.Core.Models.Table; namespace Bit.Core.Services { public class NoopCaptchaValidationService : ICaptchaValidationService { public bool ServiceEnabled => false; + public string SiteKey => null; + public bool RequireCaptcha => false; + + public string GenerateCaptchaBypassToken(User user) => ""; + public bool ValidateCaptchaBypassToken(string encryptedToken, User user) => false; public Task ValidateCaptchaResponseAsync(string captchResponse, string clientIpAddress) { diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 95ca661ebf..e8db188882 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -473,6 +473,7 @@ namespace Bit.Core.Settings public class CaptchaSettings { + public bool RequireCaptcha { get; set; } = false; public string HCaptchaSecretKey { get; set; } public string HCaptchaSiteKey { get; set; } } diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index 2c219db782..40154cdfc0 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -619,11 +619,12 @@ namespace Bit.Core.Utilities public static bool UserInviteTokenIsValid(IDataProtector protector, string token, string userEmail, Guid orgUserId, GlobalSettings globalSettings) { - return TokenIsValid("OrganizationUserInvite", protector, token, userEmail, orgUserId, globalSettings); + return TokenIsValid("OrganizationUserInvite", protector, token, userEmail, orgUserId, + globalSettings.OrganizationInviteExpirationHours); } public static bool TokenIsValid(string firstTokenPart, IDataProtector protector, string token, string userEmail, - Guid id, GlobalSettings globalSettings) + Guid id, double expirationInHours) { var invalid = true; try @@ -635,7 +636,7 @@ namespace Bit.Core.Utilities dataParts[2].Equals(userEmail, StringComparison.InvariantCultureIgnoreCase)) { var creationTime = FromEpocMilliseconds(Convert.ToInt64(dataParts[3])); - var expTime = creationTime.AddHours(globalSettings.OrganizationInviteExpirationHours); + var expTime = creationTime.AddHours(expirationInHours); invalid = expTime < DateTime.UtcNow; } } diff --git a/src/Core/Utilities/ServiceCollectionExtensions.cs b/src/Core/Utilities/ServiceCollectionExtensions.cs index 47893ead0c..f73eb6f4e3 100644 --- a/src/Core/Utilities/ServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ServiceCollectionExtensions.cs @@ -304,7 +304,7 @@ namespace Bit.Core.Utilities services.AddSingleton(); } - if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Captcha?.HCaptchaSecretKey) && + if (CoreHelpers.SettingHasValue(globalSettings.Captcha?.HCaptchaSecretKey) && CoreHelpers.SettingHasValue(globalSettings.Captcha?.HCaptchaSiteKey)) { services.AddSingleton();