From e0b231a2203736f8ae71b42d986c9b2f61e7ddef Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 26 Jun 2023 20:17:39 -0400 Subject: [PATCH] [PM-2697] Return `UserDecryptionOptions` Always (#3032) * Add Comments to UserDecryptionOptions * Move Feature Flag Check * Remove SSO Config Check * Move UserDecryptionOptions Creation - Put logic in BaseRequestValidator * Remove 'async' --- .../Api/Response/UserDecryptionOptions.cs | 6 +- .../IdentityServer/BaseRequestValidator.cs | 63 ++++++++++++- .../CustomTokenRequestValidator.cs | 94 ++++--------------- .../ResourceOwnerPasswordValidator.cs | 15 ++- .../Endpoints/IdentityServerSsoTests.cs | 57 ++++++++++- .../Endpoints/IdentityServerTests.cs | 11 +++ 6 files changed, 161 insertions(+), 85 deletions(-) diff --git a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs index 28f876035..8cf2586ce 100644 --- a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs +++ b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs @@ -12,18 +12,18 @@ public class UserDecryptionOptions : ResponseModel } /// - /// + /// Gets or sets whether the current user has a master password that can be used to decrypt their vault. /// public bool HasMasterPassword { get; set; } /// - /// + /// Gets or sets information regarding this users trusted device decryption setup. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public TrustedDeviceUserDecryptionOption? TrustedDeviceOption { get; set; } /// - /// + /// Gets or set information about the current users KeyConnector setup. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public KeyConnectorUserDecryptionOption? KeyConnectorOption { get; set; } diff --git a/src/Identity/IdentityServer/BaseRequestValidator.cs b/src/Identity/IdentityServer/BaseRequestValidator.cs index 271b9769c..9cfc19c13 100644 --- a/src/Identity/IdentityServer/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/BaseRequestValidator.cs @@ -6,7 +6,10 @@ using Bit.Core; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models; +using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -43,6 +46,8 @@ public abstract class BaseRequestValidator where T : class protected ICurrentContext CurrentContext { get; } protected IPolicyService PolicyService { get; } + protected IFeatureService FeatureService { get; } + protected ISsoConfigRepository SsoConfigRepository { get; } public BaseRequestValidator( UserManager userManager, @@ -58,10 +63,11 @@ public abstract class BaseRequestValidator where T : class ILogger logger, ICurrentContext currentContext, GlobalSettings globalSettings, - IPolicyRepository policyRepository, IUserRepository userRepository, IPolicyService policyService, - IDataProtectorTokenFactory tokenDataFactory) + IDataProtectorTokenFactory tokenDataFactory, + IFeatureService featureService, + ISsoConfigRepository ssoConfigRepository) { _userManager = userManager; _deviceRepository = deviceRepository; @@ -79,6 +85,8 @@ public abstract class BaseRequestValidator where T : class PolicyService = policyService; _userRepository = userRepository; _tokenDataFactory = tokenDataFactory; + FeatureService = featureService; + SsoConfigRepository = ssoConfigRepository; } protected async Task ValidateAsync(T context, ValidatedTokenRequest request, @@ -199,6 +207,7 @@ public abstract class BaseRequestValidator where T : class customResponse.Add("KdfIterations", user.KdfIterations); customResponse.Add("KdfMemory", user.KdfMemory); customResponse.Add("KdfParallelism", user.KdfParallelism); + customResponse.Add("UserDecryptionOptions", await CreateUserDecryptionOptionsAsync(user, GetSubject(context))); if (sendRememberToken) { @@ -300,6 +309,7 @@ public abstract class BaseRequestValidator where T : class Dictionary customResponse); protected abstract void SetErrorResult(T context, Dictionary customResponse); + protected abstract ClaimsPrincipal GetSubject(T context); private async Task> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request) { @@ -572,4 +582,53 @@ public abstract class BaseRequestValidator where T : class return new MasterPasswordPolicyResponseModel(await PolicyService.GetMasterPasswordPolicyForUserAsync(user)); } + +#nullable enable + /// + /// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents + /// + private async Task CreateUserDecryptionOptionsAsync(User user, ClaimsPrincipal subject) + { + var ssoConfigurationData = await GetSsoConfigurationDataAsync(subject); + + var userDecryptionOption = new UserDecryptionOptions + { + HasMasterPassword = !string.IsNullOrEmpty(user.MasterPassword) + }; + + if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl)) + { + // KeyConnector makes it mutually exclusive + userDecryptionOption.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl); + return userDecryptionOption; + } + + // Only add the trusted device specific option when the flag is turned on + if (FeatureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, CurrentContext) && ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }) + { + var hasAdminApproval = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.ResetPassword); + // TrustedDeviceEncryption only exists for SSO, but if that ever changes this value won't always be true + userDecryptionOption.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption(hasAdminApproval); + } + + return userDecryptionOption; + } + + private async Task GetSsoConfigurationDataAsync(ClaimsPrincipal subject) + { + var organizationClaim = subject?.FindFirstValue("organizationId"); + + if (organizationClaim == null || !Guid.TryParse(organizationClaim, out var organizationId)) + { + return null; + } + + var ssoConfig = await SsoConfigRepository.GetByOrganizationIdAsync(organizationId); + if (ssoConfig == null) + { + return null; + } + + return ssoConfig.GetData(); + } } diff --git a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs index 7096cb038..e6e39bade 100644 --- a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs @@ -1,14 +1,10 @@ using System.Security.Claims; -using Bit.Core; -using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Models.Business.Tokenables; -using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; -using Bit.Core.Enums; using Bit.Core.IdentityServer; using Bit.Core.Repositories; using Bit.Core.Services; @@ -27,8 +23,6 @@ public class CustomTokenRequestValidator : BaseRequestValidator _userManager; - private readonly ISsoConfigRepository _ssoConfigRepository; - private readonly IFeatureService _featureService; public CustomTokenRequestValidator( UserManager userManager, @@ -44,7 +38,6 @@ public class CustomTokenRequestValidator : BaseRequestValidator logger, ICurrentContext currentContext, GlobalSettings globalSettings, - IPolicyRepository policyRepository, ISsoConfigRepository ssoConfigRepository, IUserRepository userRepository, IPolicyService policyService, @@ -52,12 +45,10 @@ public class CustomTokenRequestValidator : BaseRequestValidator claims, Dictionary customResponse) { context.Result.CustomResponse = customResponse; @@ -110,23 +101,9 @@ public class CustomTokenRequestValidator : BaseRequestValidator GetSsoConfigurationDataAsync(ClaimsPrincipal? subject) + protected override ClaimsPrincipal GetSubject(CustomTokenRequestValidationContext context) { - var organizationClaim = subject?.FindFirstValue("organizationId"); - - if (organizationClaim == null || !Guid.TryParse(organizationClaim, out var organizationId)) - { - return null; - } - - var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organizationId); - if (ssoConfig == null) - { - return null; - } - - return ssoConfig.GetData(); + return context.Result.ValidatedRequest.Subject; } protected override void SetTwoFactorResult(CustomTokenRequestValidationContext context, @@ -198,29 +169,4 @@ public class CustomTokenRequestValidator : BaseRequestValidator - /// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents - /// - private async Task CreateUserDecryptionOptionsAsync(SsoConfigurationData ssoConfigurationData, User user) - { - var userDecryptionOption = new UserDecryptionOptions(); - if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl)) - { - // KeyConnector makes it mutually exclusive - userDecryptionOption.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl); - return userDecryptionOption; - } - - if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }) - { - var hasAdminApproval = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.ResetPassword); - // TrustedDeviceEncryption only exists for SSO, but if that ever changes this value won't always be true - userDecryptionOption.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption(hasAdminApproval); - } - - userDecryptionOption.HasMasterPassword = !string.IsNullOrEmpty(user.MasterPassword); - - return userDecryptionOption; - } } diff --git a/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs index c36f39b45..df17a474a 100644 --- a/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Context; using Bit.Core.Entities; @@ -37,16 +38,17 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator logger, ICurrentContext currentContext, GlobalSettings globalSettings, - IPolicyRepository policyRepository, ICaptchaValidationService captchaValidationService, IAuthRequestRepository authRequestRepository, IUserRepository userRepository, IPolicyService policyService, - IDataProtectorTokenFactory tokenDataFactory) + IDataProtectorTokenFactory tokenDataFactory, + IFeatureService featureService, + ISsoConfigRepository ssoConfigRepository) : base(userManager, deviceRepository, deviceService, userService, eventService, organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, - applicationCacheService, mailService, logger, currentContext, globalSettings, policyRepository, - userRepository, policyService, tokenDataFactory) + applicationCacheService, mailService, logger, currentContext, globalSettings, userRepository, policyService, + tokenDataFactory, featureService, ssoConfigRepository) { _userManager = userManager; _userService = userService; @@ -166,6 +168,11 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator user.MasterPassword = null); + + // Act + var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary + { + { "scope", "api offline_access" }, + { "client_id", "web" }, + { "deviceType", "10" }, + { "deviceIdentifier", "test_id" }, + { "deviceName", "firefox" }, + { "twoFactorToken", "TEST"}, + { "twoFactorProvider", "5" }, // RememberMe Provider + { "twoFactorRemember", "0" }, + { "grant_type", "authorization_code" }, + { "code", "test_code" }, + { "code_verifier", challenge }, + { "redirect_uri", "https://localhost:8080/sso-connector.html" } + })); + + // Assert + // If the organization has selected TrustedDeviceEncryption but the user still has their master password + // they can decrypt with either option + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + using var responseBody = await AssertHelper.AssertResponseTypeIs(context); + var root = responseBody.RootElement; + AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String); + var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object); + + // Expected to look like: + // "UserDecryptionOptions": { + // "Object": "userDecryptionOptions" + // "HasMasterPassword": false + // } + + // Should only have 2 properties + Assert.Equal(2, userDecryptionOptions.EnumerateObject().Count()); + } + [Fact] public async Task SsoLogin_KeyConnector_ReturnsOptions() { @@ -301,7 +354,7 @@ public class IdentityServerSsoTests Assert.Equal("https://key_connector.com", keyConnectorUrl); } - private static async Task CreateFactoryAsync(SsoConfigurationData ssoConfigurationData, string challenge) + private static async Task CreateFactoryAsync(SsoConfigurationData ssoConfigurationData, string challenge, bool trustedDeviceEnabled = true) { var factory = new IdentityApplicationFactory(); @@ -327,7 +380,7 @@ public class IdentityServerSsoTests factory.SubstitueService(service => { service.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, Arg.Any()) - .Returns(true); + .Returns(trustedDeviceEnabled); }); // This starts the server and finalizes services diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs index 316f89647..f29a09c6e 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs @@ -65,6 +65,7 @@ public class IdentityServerTests : IClassFixture Assert.Equal(0, kdf); var kdfIterations = AssertHelper.AssertJsonProperty(root, "KdfIterations", JsonValueKind.Number).GetInt32(); Assert.Equal(5000, kdfIterations); + AssertUserDecryptionOptions(root); } [Theory, BitAutoData] @@ -635,6 +636,16 @@ public class IdentityServerTests : IClassFixture Assert.StartsWith("sso authentication", errorDescription.ToLowerInvariant()); } + private static void AssertUserDecryptionOptions(JsonElement tokenResponse) + { + var userDecryptionOptions = AssertHelper.AssertJsonProperty(tokenResponse, "UserDecryptionOptions", JsonValueKind.Object) + .EnumerateObject(); + + Assert.Collection(userDecryptionOptions, + (prop) => { Assert.Equal("HasMasterPassword", prop.Name); Assert.Equal(JsonValueKind.True, prop.Value.ValueKind); }, + (prop) => { Assert.Equal("Object", prop.Name); Assert.Equal("userDecryptionOptions", prop.Value.GetString()); }); + } + private void ReinitializeDbForTests() { var databaseContext = _factory.GetDatabaseContext();