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();