1
0
mirror of https://github.com/bitwarden/server.git synced 2024-12-23 17:07:42 +01:00

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
This commit is contained in:
Matt Gibson 2021-07-21 13:42:06 -05:00 committed by GitHub
parent 259bf8d760
commit 8e1e2fa2fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 67 additions and 15 deletions

View File

@ -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.");
}

View File

@ -21,7 +21,6 @@ namespace Bit.Core.IdentityServer
private readonly IUserService _userService;
private readonly ICurrentContext _currentContext;
private readonly ICaptchaValidationService _captchaValidationService;
public ResourceOwnerPasswordValidator(
UserManager<User> 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<string, object> {
{ "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)

View File

@ -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<bool> ValidateCaptchaResponseAsync(string captchResponse, string clientIpAddress);
string GenerateCaptchaBypassToken(User user);
bool ValidateCaptchaBypassToken(string encryptedToken, User user);
}
}

View File

@ -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.");
}

View File

@ -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<HCaptchaValidationService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly GlobalSettings _globalSettings;
private readonly IDataProtector _dataProtector;
public HCaptchaValidationService(
ILogger<HCaptchaValidationService> 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<bool> 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))
});
}
}

View File

@ -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<bool> ValidateCaptchaResponseAsync(string captchResponse, string clientIpAddress)
{

View File

@ -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; }
}

View File

@ -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;
}
}

View File

@ -304,7 +304,7 @@ namespace Bit.Core.Utilities
services.AddSingleton<IReferenceEventService, AzureQueueReferenceEventService>();
}
if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Captcha?.HCaptchaSecretKey) &&
if (CoreHelpers.SettingHasValue(globalSettings.Captcha?.HCaptchaSecretKey) &&
CoreHelpers.SettingHasValue(globalSettings.Captcha?.HCaptchaSiteKey))
{
services.AddSingleton<ICaptchaValidationService, HCaptchaValidationService>();