diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 6d2dd61aa1..bab96356a8 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -28,6 +28,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; using Bit.Core.Billing.Pricing; using Bit.Core.Context; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Api; @@ -334,7 +335,7 @@ public class OrganizationUsersController : BaseAdminConsoleController ? (await _policyRequirementQuery.GetAsync(user.Id)).AutoEnrollEnabled(orgId) : await ShouldHandleResetPasswordAsync(orgId); - if (useMasterPasswordPolicy && string.IsNullOrWhiteSpace(model.ResetPasswordKey)) + if (useMasterPasswordPolicy && !OrganizationUser.IsValidResetPasswordKey(model.ResetPasswordKey)) { throw new BadRequestException("Master Password reset is required, but not provided."); } @@ -487,7 +488,7 @@ public class OrganizationUsersController : BaseAdminConsoleController var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId); var isTdeEnrollment = ssoConfig != null && ssoConfig.Enabled && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption; - if (!isTdeEnrollment && !string.IsNullOrWhiteSpace(model.ResetPasswordKey) && !await _userService.VerifySecretAsync(user, model.MasterPasswordHash)) + if (!isTdeEnrollment && OrganizationUser.IsValidResetPasswordKey(model.ResetPasswordKey) && !await _userService.VerifySecretAsync(user, model.MasterPasswordHash)) { throw new BadRequestException("Incorrect password"); } diff --git a/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs index c3378cd11d..4d360b2d90 100644 --- a/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs @@ -5,6 +5,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Models.Data; @@ -57,7 +58,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel Key = organizationDetails.Key; HasPublicAndPrivateKeys = organizationDetails.PublicKey != null && organizationDetails.PrivateKey != null; SsoBound = !string.IsNullOrWhiteSpace(organizationDetails.SsoExternalId); - ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organizationDetails.ResetPasswordKey); + ResetPasswordEnrolled = OrganizationUser.IsValidResetPasswordKey(organizationDetails.ResetPasswordKey); ProviderId = organizationDetails.ProviderId; ProviderName = organizationDetails.ProviderName; ProviderType = organizationDetails.ProviderType; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs index eb810599f3..11feba5875 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs @@ -29,7 +29,7 @@ public class OrganizationUserResponseModel : ResponseModel ExternalId = organizationUser.ExternalId; AccessSecretsManager = organizationUser.AccessSecretsManager; Permissions = CoreHelpers.LoadClassFromJsonData(organizationUser.Permissions); - ResetPasswordEnrolled = !string.IsNullOrEmpty(organizationUser.ResetPasswordKey); + ResetPasswordEnrolled = OrganizationUser.IsValidResetPasswordKey(organizationUser.ResetPasswordKey); } public OrganizationUserResponseModel(OrganizationUserUserDetails organizationUser, @@ -48,7 +48,7 @@ public class OrganizationUserResponseModel : ResponseModel ExternalId = organizationUser.ExternalId; AccessSecretsManager = organizationUser.AccessSecretsManager; Permissions = CoreHelpers.LoadClassFromJsonData(organizationUser.Permissions); - ResetPasswordEnrolled = !string.IsNullOrEmpty(organizationUser.ResetPasswordKey); + ResetPasswordEnrolled = OrganizationUser.IsValidResetPasswordKey(organizationUser.ResetPasswordKey); UsesKeyConnector = organizationUser.UsesKeyConnector; HasMasterPassword = organizationUser.HasMasterPassword; } diff --git a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs index 70da584621..9d8355a4a7 100644 --- a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs @@ -33,7 +33,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel Email = user.Email; Status = user.Status; Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); - ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey); + ResetPasswordEnrolled = OrganizationUser.IsValidResetPasswordKey(user.ResetPasswordKey); } [SetsRequiredMembers] @@ -52,7 +52,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel TwoFactorEnabled = twoFactorEnabled; Status = user.Status; Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); - ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey); + ResetPasswordEnrolled = OrganizationUser.IsValidResetPasswordKey(user.ResetPasswordKey); SsoExternalId = user.SsoExternalId; } diff --git a/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs b/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs index 835965e2d6..55d1501a98 100644 --- a/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs +++ b/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs @@ -34,7 +34,7 @@ public class OrganizationUserRotationValidator : IRotationValidator !string.IsNullOrEmpty(o.ResetPasswordKey)).ToList(); + existing = existing.Where(o => OrganizationUser.IsValidResetPasswordKey(o.ResetPasswordKey)).ToList(); foreach (var ou in existing) { @@ -44,6 +44,8 @@ public class OrganizationUserRotationValidator : IRotationValidator, IExternal, IOrganizationUser /// public bool AccessSecretsManager { get; set; } + /// + /// Checks whether the given reset password key is non-null and non-whitespace. + /// + public static bool IsValidResetPasswordKey(string? resetPasswordKey) + => !string.IsNullOrWhiteSpace(resetPasswordKey); + + /// + /// Whether this organization user is enrolled in account recovery. + /// + public bool IsEnrolledInAccountRecovery() => IsValidResetPasswordKey(ResetPasswordKey); + public void SetNewId() { Id = CoreHelpers.GenerateComb(); diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs index bd30112945..0da8a37549 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs @@ -40,7 +40,7 @@ public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepo if (organizationUser == null || organizationUser.Status != OrganizationUserStatusType.Confirmed || organizationUser.OrganizationId != orgId || - string.IsNullOrEmpty(organizationUser.ResetPasswordKey) || + !organizationUser.IsEnrolledInAccountRecovery() || !organizationUser.UserId.HasValue) { throw new BadRequestException("Organization User not valid"); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs index b6152060e8..7811c1c59c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; @@ -96,7 +97,8 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer { var organizationUsers = (await _organizationUserRepository .GetManyDetailsByOrganizationAsync_vNext(request.OrganizationId, request.IncludeGroups, request.IncludeCollections)) - .Where(o => o.Status.Equals(OrganizationUserStatusType.Confirmed) && o.UsesKeyConnector == false && !String.IsNullOrEmpty(o.ResetPasswordKey)) + .Where(o => o.Status.Equals(OrganizationUserStatusType.Confirmed) && o.UsesKeyConnector == false && + OrganizationUser.IsValidResetPasswordKey(o.ResetPasswordKey)) .ToArray(); var twoFactorTask = _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); diff --git a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs index 83d074454d..9f80d402c9 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs @@ -6,6 +6,7 @@ using Bit.Core.Dirt.Reports.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Dirt.Reports.Repositories; +using Bit.Core.Entities; using Bit.Core.Services; using Microsoft.Extensions.Logging; @@ -65,7 +66,7 @@ public class MemberAccessReportQuery( UserName = g.Key.UserName, Email = g.Key.Email, TwoFactorEnabled = orgUsersTwoFactorEnabled.FirstOrDefault(x => x.userId == g.Key.UserGuid).twoFactorIsEnabled, - AccountRecoveryEnabled = !string.IsNullOrWhiteSpace(g.Key.ResetPasswordKey) && orgAbility.UseResetPassword, + AccountRecoveryEnabled = OrganizationUser.IsValidResetPasswordKey(g.Key.ResetPasswordKey) && orgAbility.UseResetPassword, UsesKeyConnector = g.Key.UsesKeyConnector, GroupId = g.Key.GroupId, GroupName = g.Key.GroupName, diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index a08fe58c5d..25bc577dcc 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -581,7 +581,8 @@ public class UserService : UserManager, IUserService // Org User must be confirmed and have a ResetPasswordKey var orgUser = await _organizationUserRepository.GetByIdAsync(id); if (orgUser == null || orgUser.Status != OrganizationUserStatusType.Confirmed || - orgUser.OrganizationId != orgId || string.IsNullOrEmpty(orgUser.ResetPasswordKey) || + orgUser.OrganizationId != orgId || + !orgUser.IsEnrolledInAccountRecovery() || !orgUser.UserId.HasValue) { throw new BadRequestException("Organization User not valid"); diff --git a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs index 003e9a032e..43b0f064a7 100644 --- a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs +++ b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs @@ -145,8 +145,8 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id); var hasManageResetPasswordPermission = await EvaluateHasManageResetPasswordPermission(); - // They are only able to be approved by an admin if they have enrolled is reset password - var hasAdminApproval = organizationUser != null && !string.IsNullOrEmpty(organizationUser.ResetPasswordKey); + // They are only able to be approved by an admin if they have enrolled in account recovery + var hasAdminApproval = organizationUser != null && organizationUser.IsEnrolledInAccountRecovery(); _options.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption( hasAdminApproval, diff --git a/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs b/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs index 468e7850fb..b70827024c 100644 --- a/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs +++ b/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs @@ -10,14 +10,13 @@ namespace Bit.Api.Test.AdminConsole.Public.Models.Response; public class MemberResponseModelTests { [Fact] - public void ResetPasswordEnrolled_ShouldBeTrue_WhenUserHasResetPasswordKey() + public void ResetPasswordEnrolled_ShouldBeTrue_WhenUserIsResetPasswordEnrolled() { // Arrange var user = Substitute.For(); var collections = Substitute.For>(); user.ResetPasswordKey = "none-empty"; - // Act var sut = new MemberResponseModel(user, collections); diff --git a/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs b/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs index a939636fc2..88f2e482b7 100644 --- a/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs +++ b/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs @@ -199,4 +199,77 @@ public class OrganizationUserRotationValidatorTests await Assert.ThrowsAsync(async () => await sutProvider.Sut.ValidateAsync(user, Enumerable.Empty())); } + + // TODO: Remove this test after https://bitwarden.atlassian.net/browse/PM-31001 is resolved. + // Clients currently send "" as a reset password key value during rotation due to a client-side bug. + // The server must accept "" to avoid blocking key rotation for affected users. + // After PM-31001 is fixed, this should be replaced with a test asserting that "" throws BadRequestException. + [Theory] + [BitAutoData("")] + [BitAutoData(" ")] + public async Task ValidateAsync_EmptyOrWhitespaceKey_AcceptedDueToClientBug( + string emptyKey, + SutProvider sutProvider, User user, + ResetPasswordWithOrgIdRequestModel validResetPasswordKey) + { + // Arrange + var existingUserResetPassword = new List + { + new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = validResetPasswordKey.OrganizationId, + ResetPasswordKey = "existing-valid-key" + } + }; + sutProvider.GetDependency().GetManyByUserAsync(user.Id) + .Returns(existingUserResetPassword); + + // Set the incoming key to empty/whitespace (simulating client bug) + validResetPasswordKey.ResetPasswordKey = emptyKey; + + // Act — rotation should succeed (not throw) to preserve backward compatibility + var result = await sutProvider.Sut.ValidateAsync(user, new[] { validResetPasswordKey }); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal(emptyKey, result[0].ResetPasswordKey); + } + + [Theory] + [BitAutoData(" ")] + public async Task ValidateAsync_WhitespaceOnlyExistingKey_FiltersOut( + string whitespaceKey, + SutProvider sutProvider, User user, + ResetPasswordWithOrgIdRequestModel validResetPasswordKey) + { + // Arrange + var existingUserResetPassword = new List + { + new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = validResetPasswordKey.OrganizationId, + ResetPasswordKey = validResetPasswordKey.ResetPasswordKey + }, + // Whitespace-only key should be filtered out + new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + ResetPasswordKey = whitespaceKey + } + }; + sutProvider.GetDependency().GetManyByUserAsync(user.Id) + .Returns(existingUserResetPassword); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, new[] { validResetPasswordKey }); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal(validResetPasswordKey.OrganizationId, result[0].OrganizationId); + } } diff --git a/test/Core.Test/AdminConsole/Entities/OrganizationUserTests.cs b/test/Core.Test/AdminConsole/Entities/OrganizationUserTests.cs new file mode 100644 index 0000000000..953ebbd041 --- /dev/null +++ b/test/Core.Test/AdminConsole/Entities/OrganizationUserTests.cs @@ -0,0 +1,38 @@ +using Bit.Core.Entities; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Entities; + +public class OrganizationUserTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsValidResetPasswordKey_InvalidKeys_ReturnsFalse(string? resetPasswordKey) + { + Assert.False(OrganizationUser.IsValidResetPasswordKey(resetPasswordKey)); + } + + [Fact] + public void IsValidResetPasswordKey_ValidKey_ReturnsTrue() + { + Assert.True(OrganizationUser.IsValidResetPasswordKey("validKey")); + } + + [Fact] + public void IsEnrolledInAccountRecovery_NullKey_ReturnsFalse() + { + var orgUser = new OrganizationUser { ResetPasswordKey = null }; + + Assert.False(orgUser.IsEnrolledInAccountRecovery()); + } + + [Fact] + public void IsEnrolledInAccountRecovery_ValidKey_ReturnsTrue() + { + var orgUser = new OrganizationUser { ResetPasswordKey = "validKey" }; + + Assert.True(orgUser.IsEnrolledInAccountRecovery()); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs index 3095907a22..ed6aa31911 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs @@ -150,6 +150,15 @@ public class AdminRecoverAccountCommandTests }; yield return [emptyResetPasswordKey, organization]; + var whitespaceResetPasswordKey = new OrganizationUser + { + Status = OrganizationUserStatusType.Confirmed, + OrganizationId = organization.Id, + ResetPasswordKey = " ", + UserId = Guid.NewGuid(), + }; + yield return [whitespaceResetPasswordKey, organization]; + var nullUserId = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQueryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQueryTests.cs new file mode 100644 index 0000000000..074a2f14bf --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQueryTests.cs @@ -0,0 +1,107 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; + +[SutProviderCustomize] +public class OrganizationUserUserDetailsQueryTests +{ + [Theory] + [BitAutoData(" ")] + public async Task GetAccountRecoveryEnrolledUsers_InvalidKey_FiltersOut( + string invalidKey, + Guid orgId, + SutProvider sutProvider) + { + // Arrange + var request = new OrganizationUserUserDetailsQueryRequest { OrganizationId = orgId }; + + var validUser = CreateOrgUserDetails(orgId, "valid-key"); + var invalidUser = CreateOrgUserDetails(orgId, invalidKey); + var allUsers = new[] { validUser, invalidUser }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync_vNext(orgId, false, false) + .Returns(allUsers); + + SetupTwoFactorAndClaimedStatus(sutProvider, orgId); + + // Act + var result = (await sutProvider.Sut.GetAccountRecoveryEnrolledUsers(request)).ToList(); + + // Assert - invalid key user should be filtered out + Assert.Single(result); + Assert.Equal(validUser.Id, result[0].OrgUser.Id); + } + + [Theory] + [BitAutoData] + public async Task GetAccountRecoveryEnrolledUsers_NullKey_FiltersOut( + Guid orgId, + SutProvider sutProvider) + { + // Arrange + var request = new OrganizationUserUserDetailsQueryRequest { OrganizationId = orgId }; + + var validUser = CreateOrgUserDetails(orgId, "valid-key"); + var nullUser = CreateOrgUserDetails(orgId, null!); + var allUsers = new[] { validUser, nullUser }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync_vNext(orgId, false, false) + .Returns(allUsers); + + SetupTwoFactorAndClaimedStatus(sutProvider, orgId); + + // Act + var result = (await sutProvider.Sut.GetAccountRecoveryEnrolledUsers(request)).ToList(); + + // Assert + Assert.Single(result); + Assert.Equal(validUser.Id, result[0].OrgUser.Id); + } + + private static OrganizationUserUserDetails CreateOrgUserDetails(Guid orgId, string resetPasswordKey) + { + return new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + UserId = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + UsesKeyConnector = false, + ResetPasswordKey = resetPasswordKey, + Email = "test@example.com" + }; + } + + private static void SetupTwoFactorAndClaimedStatus( + SutProvider sutProvider, Guid orgId) + { + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns(callInfo => + { + var users = callInfo.Arg>(); + return users.Select(u => (user: u, twoFactorIsEnabled: false)).ToList(); + }); + + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(Arg.Any(), Arg.Any>()) + .Returns(callInfo => + { + var userIds = callInfo.Arg>(); + return userIds.ToDictionary(id => id, _ => false); + }); + } +} diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 1d26313852..d6407a8058 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -22,6 +22,7 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -595,6 +596,41 @@ public class UserServiceTests user.MasterPassword = null; } } + + [Theory] + [BitAutoData("")] + [BitAutoData(" ")] + [BitAutoData("\t")] + public async Task AdminResetPasswordAsync_EmptyOrWhitespaceResetPasswordKey_ThrowsBadRequest( + string resetPasswordKey, + SutProvider sutProvider, + Organization organization, + OrganizationUser orgUser, + [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy) + { + // Arrange + organization.UseResetPassword = true; + orgUser.Status = OrganizationUserStatusType.Confirmed; + orgUser.OrganizationId = organization.Id; + orgUser.ResetPasswordKey = resetPasswordKey; + orgUser.UserId = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.ResetPassword) + .Returns(policy); + sutProvider.GetDependency() + .GetByIdAsync(orgUser.Id) + .Returns(orgUser); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.AdminResetPasswordAsync( + OrganizationUserType.Owner, organization.Id, orgUser.Id, "newPassword", "key")); + Assert.Equal("Organization User not valid", exception.Message); + } } public static class UserServiceSutProviderExtensions diff --git a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs index d39da7f3e4..a8cb99e962 100644 --- a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs +++ b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs @@ -299,6 +299,27 @@ public class UserDecryptionOptionsBuilderTests Assert.True(result.TrustedDeviceOption?.HasAdminApproval); } + [Theory] + [BitAutoData("")] + [BitAutoData(" ")] + [BitAutoData((string)null)] + public async Task Build_EmptyOrWhitespaceResetPasswordKey_ShouldReturnHasAdminApprovalFalse( + string resetPasswordKey, + SsoConfig ssoConfig, + SsoConfigurationData configurationData, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, + User user) + { + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + organizationUser.ResetPasswordKey = resetPasswordKey; + _organizationUserRepository.GetByOrganizationAsync(ssoConfig.OrganizationId, user.Id).Returns(organizationUser); + + var result = await _builder.ForUser(user).WithSso(ssoConfig).BuildAsync(); + + Assert.False(result.TrustedDeviceOption?.HasAdminApproval); + } + [Theory, BitAutoData] public async Task Build_WhenUserHasNoMasterPassword_ShouldReturnNoMasterPasswordUnlock(User user) {