diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index c927138daf..533afbbbad 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -5,6 +5,7 @@ using Bit.Api.Models.Request; using Bit.Api.Models.Response; using Bit.Core.Auth.Enums; using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Utilities; using Bit.Core.Context; using Bit.Core.Entities; @@ -12,6 +13,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Tokens; using Bit.Core.Utilities; using Fido2NetLib; using Microsoft.AspNetCore.Authorization; @@ -31,6 +33,7 @@ public class TwoFactorController : Controller private readonly UserManager _userManager; private readonly ICurrentContext _currentContext; private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand; + private readonly IDataProtectorTokenFactory _tokenDataFactory; public TwoFactorController( IUserService userService, @@ -39,7 +42,8 @@ public class TwoFactorController : Controller GlobalSettings globalSettings, UserManager userManager, ICurrentContext currentContext, - IVerifyAuthRequestCommand verifyAuthRequestCommand) + IVerifyAuthRequestCommand verifyAuthRequestCommand, + IDataProtectorTokenFactory tokenDataFactory) { _userService = userService; _organizationRepository = organizationRepository; @@ -48,6 +52,7 @@ public class TwoFactorController : Controller _userManager = userManager; _currentContext = currentContext; _verifyAuthRequestCommand = verifyAuthRequestCommand; + _tokenDataFactory = tokenDataFactory; } [HttpGet("")] @@ -85,7 +90,8 @@ public class TwoFactorController : Controller } [HttpPost("get-authenticator")] - public async Task GetAuthenticator([FromBody] SecretVerificationRequestModel model) + public async Task GetAuthenticator( + [FromBody] SecretVerificationRequestModel model) { var user = await CheckAsync(model, false); var response = new TwoFactorAuthenticatorResponseModel(user); @@ -101,7 +107,7 @@ public class TwoFactorController : Controller model.ToUser(user); if (!await _userManager.VerifyTwoFactorTokenAsync(user, - CoreHelpers.CustomProviderName(TwoFactorProviderType.Authenticator), model.Token)) + CoreHelpers.CustomProviderName(TwoFactorProviderType.Authenticator), model.Token)) { await Task.Delay(2000); throw new BadRequestException("Token", "Invalid token."); @@ -158,7 +164,8 @@ public class TwoFactorController : Controller } catch (DuoException) { - throw new BadRequestException("Duo configuration settings are not valid. Please re-check the Duo Admin panel."); + throw new BadRequestException( + "Duo configuration settings are not valid. Please re-check the Duo Admin panel."); } model.ToUser(user); @@ -215,7 +222,8 @@ public class TwoFactorController : Controller } catch (DuoException) { - throw new BadRequestException("Duo configuration settings are not valid. Please re-check the Duo Admin panel."); + throw new BadRequestException( + "Duo configuration settings are not valid. Please re-check the Duo Admin panel."); } model.ToOrganization(organization); @@ -254,12 +262,14 @@ public class TwoFactorController : Controller { throw new BadRequestException("Unable to complete WebAuthn registration."); } + var response = new TwoFactorWebAuthnResponseModel(user); return response; } [HttpDelete("webauthn")] - public async Task DeleteWebAuthn([FromBody] TwoFactorWebAuthnDeleteRequestModel model) + public async Task DeleteWebAuthn( + [FromBody] TwoFactorWebAuthnDeleteRequestModel model) { var user = await CheckAsync(model, true); await _userService.DeleteWebAuthnKeyAsync(user, model.Id.Value); @@ -285,30 +295,46 @@ public class TwoFactorController : Controller [AllowAnonymous] [HttpPost("send-email-login")] - public async Task SendEmailLogin([FromBody] TwoFactorEmailRequestModel model) + public async Task SendEmailLoginAsync([FromBody] TwoFactorEmailRequestModel requestModel) { - var user = await _userManager.FindByEmailAsync(model.Email.ToLowerInvariant()); + var user = await _userManager.FindByEmailAsync(requestModel.Email.ToLowerInvariant()); + if (user != null) { // check if 2FA email is from passwordless - if (!string.IsNullOrEmpty(model.AuthRequestAccessCode)) + if (!string.IsNullOrEmpty(requestModel.AuthRequestAccessCode)) { if (await _verifyAuthRequestCommand - .VerifyAuthRequestAsync(new Guid(model.AuthRequestId), model.AuthRequestAccessCode)) + .VerifyAuthRequestAsync(new Guid(requestModel.AuthRequestId), + requestModel.AuthRequestAccessCode)) { await _userService.SendTwoFactorEmailAsync(user); return; } } - else if (await _userService.VerifySecretAsync(user, model.Secret)) + else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken)) + { + if (this.ValidateSsoEmail2FaToken(requestModel.SsoEmail2FaSessionToken, user)) + { + await _userService.SendTwoFactorEmailAsync(user); + return; + } + else + { + await this.ThrowDelayedBadRequestExceptionAsync( + "Cannot send two-factor email: a valid, non-expired SSO Email 2FA Session token is required to send 2FA emails.", + 2000); + } + } + else if (await _userService.VerifySecretAsync(user, requestModel.Secret)) { await _userService.SendTwoFactorEmailAsync(user); return; } } - await Task.Delay(2000); - throw new BadRequestException("Cannot send two-factor email."); + await this.ThrowDelayedBadRequestExceptionAsync( + "Cannot send two-factor email.", 2000); } [HttpPut("email")] @@ -319,7 +345,7 @@ public class TwoFactorController : Controller model.ToUser(user); if (!await _userManager.VerifyTwoFactorTokenAsync(user, - CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), model.Token)) + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), model.Token)) { await Task.Delay(2000); throw new BadRequestException("Token", "Invalid token."); @@ -377,7 +403,7 @@ public class TwoFactorController : Controller public async Task PostRecover([FromBody] TwoFactorRecoveryRequestModel model) { if (!await _userService.RecoverTwoFactorAsync(model.Email, model.MasterPasswordHash, model.RecoveryCode, - _organizationService)) + _organizationService)) { await Task.Delay(2000); throw new BadRequestException(string.Empty, "Invalid information. Try again."); @@ -393,7 +419,8 @@ public class TwoFactorController : Controller [Obsolete("Leaving this for backwards compatibilty on clients")] [HttpPut("device-verification-settings")] - public Task PutDeviceVerificationSettings([FromBody] DeviceVerificationRequestModel model) + public Task PutDeviceVerificationSettings( + [FromBody] DeviceVerificationRequestModel model) { return Task.FromResult(new DeviceVerificationResponseModel(false, false)); } @@ -428,7 +455,7 @@ public class TwoFactorController : Controller } if (!await _userManager.VerifyTwoFactorTokenAsync(user, - CoreHelpers.CustomProviderName(TwoFactorProviderType.YubiKey), value)) + CoreHelpers.CustomProviderName(TwoFactorProviderType.YubiKey), value)) { await Task.Delay(2000); throw new BadRequestException(name, $"{name} is invalid."); @@ -438,4 +465,16 @@ public class TwoFactorController : Controller await Task.Delay(500); } } + + private bool ValidateSsoEmail2FaToken(string ssoEmail2FaSessionToken, User user) + { + return _tokenDataFactory.TryUnprotect(ssoEmail2FaSessionToken, out var decryptedToken) && + decryptedToken.Valid && decryptedToken.TokenIsValid(user); + } + + private async Task ThrowDelayedBadRequestExceptionAsync(string message, int delayTime = 2000) + { + await Task.Delay(delayTime); + throw new BadRequestException(message); + } } diff --git a/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs index ee69301281..c0191728f4 100644 --- a/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs @@ -14,7 +14,7 @@ public class SecretVerificationRequestModel : IValidatableObject { if (string.IsNullOrEmpty(Secret) && string.IsNullOrEmpty(AuthRequestAccessCode)) { - yield return new ValidationResult("MasterPasswordHash, OTP or AccessCode must be supplied."); + yield return new ValidationResult("MasterPasswordHash, OTP, or AccessCode must be supplied."); } } } diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 9759fb70b4..a9d5db2255 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -202,9 +202,9 @@ public class TwoFactorEmailRequestModel : SecretVerificationRequestModel [EmailAddress] [StringLength(256)] public string Email { get; set; } - public string AuthRequestId { get; set; } - + // An auth session token used for obtaining email and as an authN factor for the sending of emailed 2FA OTPs. + public string SsoEmail2FaSessionToken { get; set; } public User ToUser(User extistingUser) { var providers = extistingUser.GetTwoFactorProviders(); @@ -225,6 +225,14 @@ public class TwoFactorEmailRequestModel : SecretVerificationRequestModel extistingUser.SetTwoFactorProviders(providers); return extistingUser; } + + public override IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrEmpty(Secret) && string.IsNullOrEmpty(AuthRequestAccessCode) && string.IsNullOrEmpty((SsoEmail2FaSessionToken))) + { + yield return new ValidationResult("MasterPasswordHash, OTP, AccessCode, or SsoEmail2faSessionToken must be supplied."); + } + } } public class TwoFactorWebAuthnRequestModel : TwoFactorWebAuthnDeleteRequestModel diff --git a/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs new file mode 100644 index 0000000000..e8c8a33f56 --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Serialization; +using Bit.Core.Entities; +using Bit.Core.Tokens; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +// This token just provides a verifiable authN mechanism for the API service +// TwoFactorController.cs SendEmailLogin anonymous endpoint so it cannot be +// used maliciously. +public class SsoEmail2faSessionTokenable : ExpiringTokenable +{ + // Just over 2 min expiration (client expires session after 2 min) + private static readonly TimeSpan _tokenLifetime = TimeSpan.FromMinutes(2.05); + public const string ClearTextPrefix = "BwSsoEmail2FaSessionToken_"; + public const string DataProtectorPurpose = "SsoEmail2faSessionTokenDataProtector"; + + public const string TokenIdentifier = "SsoEmail2faSessionToken"; + + public string Identifier { get; set; } = TokenIdentifier; + public Guid Id { get; set; } + public string Email { get; set; } + + + [JsonConstructor] + public SsoEmail2faSessionTokenable() + { + ExpirationDate = DateTime.UtcNow.Add(_tokenLifetime); + } + + public SsoEmail2faSessionTokenable(User user) : this() + { + Id = user?.Id ?? default; + Email = user?.Email; + } + + public bool TokenIsValid(User user) + { + if (Id == default || Email == default || user == null) + { + return false; + } + + return Id == user.Id && + Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase); + } + + // Validates deserialized + protected override bool TokenIsValid() => + Identifier == TokenIdentifier && Id != default && !string.IsNullOrWhiteSpace(Email); +} diff --git a/src/Identity/IdentityServer/BaseRequestValidator.cs b/src/Identity/IdentityServer/BaseRequestValidator.cs index 54d9715634..e3bf7bbd11 100644 --- a/src/Identity/IdentityServer/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/BaseRequestValidator.cs @@ -6,6 +6,7 @@ using Bit.Core; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -16,6 +17,7 @@ using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Tokens; using Bit.Core.Utilities; using IdentityServer4.Validation; using Microsoft.AspNetCore.Identity; @@ -40,6 +42,7 @@ public abstract class BaseRequestValidator where T : class private readonly IPolicyRepository _policyRepository; private readonly IUserRepository _userRepository; private readonly IPolicyService _policyService; + private readonly IDataProtectorTokenFactory _tokenDataFactory; public BaseRequestValidator( UserManager userManager, @@ -57,7 +60,8 @@ public abstract class BaseRequestValidator where T : class GlobalSettings globalSettings, IPolicyRepository policyRepository, IUserRepository userRepository, - IPolicyService policyService) + IPolicyService policyService, + IDataProtectorTokenFactory tokenDataFactory) { _userManager = userManager; _deviceRepository = deviceRepository; @@ -75,6 +79,7 @@ public abstract class BaseRequestValidator where T : class _policyRepository = policyRepository; _userRepository = userRepository; _policyService = policyService; + _tokenDataFactory = tokenDataFactory; } protected async Task ValidateAsync(T context, ValidatedTokenRequest request, @@ -92,7 +97,7 @@ public abstract class BaseRequestValidator where T : class var twoFactorProvider = request.Raw["TwoFactorProvider"]?.ToString(); var twoFactorRemember = request.Raw["TwoFactorRemember"]?.ToString() == "1"; var twoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) && - !string.IsNullOrWhiteSpace(twoFactorProvider); + !string.IsNullOrWhiteSpace(twoFactorProvider); var valid = await ValidateContextAsync(context, validatorContext); var user = validatorContext.User; @@ -100,6 +105,7 @@ public abstract class BaseRequestValidator where T : class { await UpdateFailedAuthDetailsAsync(user, false, !validatorContext.KnownDevice); } + if (!valid || isBot) { await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user); @@ -150,14 +156,16 @@ public abstract class BaseRequestValidator where T : class await BuildErrorResultAsync("No device information provided.", false, context, user); return; } + await BuildSuccessResultAsync(user, context, device, twoFactorRequest && twoFactorRemember); } else { - SetSsoResult(context, new Dictionary - {{ - "ErrorModel", new ErrorResponseModel("SSO authentication is required.") - }}); + SetSsoResult(context, + new Dictionary + { + { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } + }); } } @@ -240,13 +248,23 @@ public abstract class BaseRequestValidator where T : class providers.Add(((byte)provider.Key).ToString(), infoDict); } - SetTwoFactorResult(context, - new Dictionary - { - { "TwoFactorProviders", providers.Keys }, - { "TwoFactorProviders2", providers }, - { "MasterPasswordPolicy", await GetMasterPasswordPolicy(user) } - }); + var twoFactorResultDict = new Dictionary + { + { "TwoFactorProviders", providers.Keys }, + { "TwoFactorProviders2", providers }, + { "MasterPasswordPolicy", await GetMasterPasswordPolicy(user) }, + }; + + // If we have email as a 2FA provider, we might need an SsoEmail2fa Session Token + if (enabledProviders.Any(p => p.Key == TwoFactorProviderType.Email)) + { + twoFactorResultDict.Add("SsoEmail2faSessionToken", + _tokenDataFactory.Protect(new SsoEmail2faSessionTokenable(user))); + + twoFactorResultDict.Add("Email", user.Email); + } + + SetTwoFactorResult(context, twoFactorResultDict); if (enabledProviders.Count() == 1 && enabledProviders.First().Key == TwoFactorProviderType.Email) { @@ -272,10 +290,7 @@ public abstract class BaseRequestValidator where T : class await Task.Delay(2000); // Delay for brute force. SetErrorResult(context, - new Dictionary - {{ - "ErrorModel", new ErrorResponseModel(message) - }}); + new Dictionary { { "ErrorModel", new ErrorResponseModel(message) } }); } protected abstract void SetTwoFactorResult(T context, Dictionary customResponse); @@ -296,8 +311,8 @@ public abstract class BaseRequestValidator where T : class } var individualRequired = _userManager.SupportsUserTwoFactor && - await _userManager.GetTwoFactorEnabledAsync(user) && - (await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0; + await _userManager.GetTwoFactorEnabledAsync(user) && + (await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0; Organization firstEnabledOrg = null; var orgs = (await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)) @@ -346,7 +361,8 @@ public abstract class BaseRequestValidator where T : class PolicyType.RequireSso); // Owners and Admins are exempt from this policy if (orgPolicy != null && orgPolicy.Enabled && - (_globalSettings.Sso.EnforceSsoPolicyForAllUsers || (userOrg.Type != OrganizationUserType.Owner && userOrg.Type != OrganizationUserType.Admin))) + (_globalSettings.Sso.EnforceSsoPolicyForAllUsers || + (userOrg.Type != OrganizationUserType.Owner && userOrg.Type != OrganizationUserType.Admin))) { return false; } @@ -361,7 +377,7 @@ public abstract class BaseRequestValidator where T : class private bool OrgUsing2fa(IDictionary orgAbilities, Guid orgId) { return orgAbilities != null && orgAbilities.ContainsKey(orgId) && - orgAbilities[orgId].Enabled && orgAbilities[orgId].Using2fa; + orgAbilities[orgId].Enabled && orgAbilities[orgId].Using2fa; } private bool OrgCanUseSso(IDictionary orgAbilities, Guid orgId) @@ -408,6 +424,7 @@ public abstract class BaseRequestValidator where T : class { return false; } + return await _userManager.VerifyTwoFactorTokenAsync(user, CoreHelpers.CustomProviderName(type), token); case TwoFactorProviderType.OrganizationDuo: @@ -457,18 +474,13 @@ public abstract class BaseRequestValidator where T : class } else if (type == TwoFactorProviderType.Email) { - return new Dictionary - { - ["Email"] = token - }; + return new Dictionary { ["Email"] = token }; } else if (type == TwoFactorProviderType.YubiKey) { - return new Dictionary - { - ["Nfc"] = (bool)provider.MetaData["Nfc"] - }; + return new Dictionary { ["Nfc"] = (bool)provider.MetaData["Nfc"] }; } + return null; case TwoFactorProviderType.OrganizationDuo: if (await _organizationDuoWebTokenProvider.CanGenerateTwoFactorTokenAsync(organization)) @@ -479,6 +491,7 @@ public abstract class BaseRequestValidator where T : class ["Signature"] = await _organizationDuoWebTokenProvider.GenerateAsync(organization, user) }; } + return null; default: return null; diff --git a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs index 02c48cb67f..aef5f5c544 100644 --- a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using Bit.Core.Auth.Identity; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; @@ -7,6 +8,7 @@ using Bit.Core.IdentityServer; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Tokens; using IdentityModel; using IdentityServer4.Extensions; using IdentityServer4.Validation; @@ -37,11 +39,12 @@ public class CustomTokenRequestValidator : BaseRequestValidator tokenDataFactory) : base(userManager, deviceRepository, deviceService, userService, eventService, - organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, - applicationCacheService, mailService, logger, currentContext, globalSettings, policyRepository, - userRepository, policyService) + organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, + applicationCacheService, mailService, logger, currentContext, globalSettings, policyRepository, + userRepository, policyService, tokenDataFactory) { _userManager = userManager; _ssoConfigRepository = ssoConfigRepository; @@ -73,11 +76,13 @@ public class CustomTokenRequestValidator : BaseRequestValidator claim.Type == JwtClaimTypes.Email)?.Value; + ?? context.Result.ValidatedRequest.ClientClaims + ?.FirstOrDefault(claim => claim.Type == JwtClaimTypes.Email)?.Value; if (!string.IsNullOrWhiteSpace(email)) { validatorContext.User = await _userManager.FindByEmailAsync(email); } + return validatorContext.User != null; } @@ -111,6 +116,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator tokenDataFactory) : base(userManager, deviceRepository, deviceService, userService, eventService, organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, applicationCacheService, mailService, logger, currentContext, globalSettings, policyRepository, - userRepository, policyService) + userRepository, policyService, tokenDataFactory) { _userManager = userManager; _userService = userService; diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index b01be7a28b..b0a0f09ad6 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -157,6 +157,12 @@ public static class ServiceCollectionExtensions SsoTokenable.DataProtectorPurpose, serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + SsoEmail2faSessionTokenable.ClearTextPrefix, + SsoEmail2faSessionTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>())); } public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings)