1
0
mirror of https://github.com/bitwarden/server synced 2026-02-25 00:52:57 +00:00

[PM-21179] Add interface to check if user is enrolled in account recovery (#6993)

* Add validation for reset password key and account recovery enrollment in OrganizationUser

* Update admin approval logic to check account recovery enrollment and add tests for reset password key validation

* Enhance UserService validation to include account recovery enrollment and add unit test for empty or whitespace reset password key handling

* Refactor OrganizationUserUserDetailsQuery to validate reset password keys and add unit tests for filtering out invalid keys

* Update AdminRecoverAccountCommand to validate account recovery enrollment and adjust tests for whitespace reset password keys

* Enhance OrganizationUserRotationValidator to validate reset password keys, including filtering out whitespace-only keys, and add corresponding unit tests for validation logic.

* Refactor OrganizationUserUserDetailsQueryTests to remove unnecessary whitespace-only test cases for account recovery key validation.

* Refactor MemberResponseModel to use OrganizationUser's validation method for ResetPasswordEnrolled status and update corresponding unit test for clarity.

* Refactor OrganizationUsersController and response models to utilize OrganizationUser's validation method for ResetPasswordKey, ensuring consistent validation across the application. Add unit tests for OrganizationUser to verify key validation logic.

* Update OrganizationUserRotationValidator to handle null reset password keys and adjust tests for client-side bug. Add comments for future migration after resolving PM-31001.

* Fix whitespace issue in UserServiceTests.cs by removing BOM character from the file header.
This commit is contained in:
Rui Tomé
2026-02-24 14:16:54 +00:00
committed by GitHub
parent 9eccb0001d
commit ef4f4e352f
18 changed files with 318 additions and 16 deletions

View File

@@ -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<OrganizationUser>();
var collections = Substitute.For<IEnumerable<CollectionAccessSelection>>();
user.ResetPasswordKey = "none-empty";
// Act
var sut = new MemberResponseModel(user, collections);

View File

@@ -199,4 +199,77 @@ public class OrganizationUserRotationValidatorTests
await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.ValidateAsync(user, Enumerable.Empty<ResetPasswordWithOrgIdRequestModel>()));
}
// 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<OrganizationUserRotationValidator> sutProvider, User user,
ResetPasswordWithOrgIdRequestModel validResetPasswordKey)
{
// Arrange
var existingUserResetPassword = new List<OrganizationUser>
{
new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = validResetPasswordKey.OrganizationId,
ResetPasswordKey = "existing-valid-key"
}
};
sutProvider.GetDependency<IOrganizationUserRepository>().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<OrganizationUserRotationValidator> sutProvider, User user,
ResetPasswordWithOrgIdRequestModel validResetPasswordKey)
{
// Arrange
var existingUserResetPassword = new List<OrganizationUser>
{
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<IOrganizationUserRepository>().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);
}
}

View File

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

View File

@@ -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,

View File

@@ -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<OrganizationUserUserDetailsQuery> 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<IOrganizationUserRepository>()
.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<OrganizationUserUserDetailsQuery> 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<IOrganizationUserRepository>()
.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<OrganizationUserUserDetailsQuery> sutProvider, Guid orgId)
{
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<OrganizationUserUserDetails>>())
.Returns(callInfo =>
{
var users = callInfo.Arg<IEnumerable<OrganizationUserUserDetails>>();
return users.Select(u => (user: u, twoFactorIsEnabled: false)).ToList();
});
sutProvider.GetDependency<IGetOrganizationUsersClaimedStatusQuery>()
.GetUsersOrganizationClaimedStatusAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>())
.Returns(callInfo =>
{
var userIds = callInfo.Arg<IEnumerable<Guid>>();
return userIds.ToDictionary(id => id, _ => false);
});
}
}

View File

@@ -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<UserService> 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<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(organization.Id, PolicyType.ResetPassword)
.Returns(policy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(orgUser.Id)
.Returns(orgUser);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.AdminResetPasswordAsync(
OrganizationUserType.Owner, organization.Id, orgUser.Id, "newPassword", "key"));
Assert.Equal("Organization User not valid", exception.Message);
}
}
public static class UserServiceSutProviderExtensions

View File

@@ -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)
{