1
0
mirror of https://github.com/bitwarden/server synced 2026-02-26 09:23:28 +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

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

View File

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

View File

@@ -29,7 +29,7 @@ public class OrganizationUserResponseModel : ResponseModel
ExternalId = organizationUser.ExternalId;
AccessSecretsManager = organizationUser.AccessSecretsManager;
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(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<Permissions>(organizationUser.Permissions);
ResetPasswordEnrolled = !string.IsNullOrEmpty(organizationUser.ResetPasswordKey);
ResetPasswordEnrolled = OrganizationUser.IsValidResetPasswordKey(organizationUser.ResetPasswordKey);
UsesKeyConnector = organizationUser.UsesKeyConnector;
HasMasterPassword = organizationUser.HasMasterPassword;
}

View File

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

View File

@@ -34,7 +34,7 @@ public class OrganizationUserRotationValidator : IRotationValidator<IEnumerable<
}
// Exclude any account recovery that do not have a key.
existing = existing.Where(o => !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<IEnumerable<
throw new BadRequestException("All existing reset password keys must be included in the rotation.");
}
// Should be migrated to: if (!OrganizationUser.IsValidResetPasswordKey(organizationUser.ResetPasswordKey))
// after https://bitwarden.atlassian.net/browse/PM-31001 is resolved
if (organizationUser.ResetPasswordKey == null)
{
throw new BadRequestException("Reset Password keys cannot be set to null during rotation.");

View File

@@ -81,6 +81,17 @@ public class OrganizationUser : ITableObject<Guid>, IExternal, IOrganizationUser
/// </summary>
public bool AccessSecretsManager { get; set; }
/// <summary>
/// Checks whether the given reset password key is non-null and non-whitespace.
/// </summary>
public static bool IsValidResetPasswordKey(string? resetPasswordKey)
=> !string.IsNullOrWhiteSpace(resetPasswordKey);
/// <summary>
/// Whether this organization user is enrolled in account recovery.
/// </summary>
public bool IsEnrolledInAccountRecovery() => IsValidResetPasswordKey(ResetPasswordKey);
public void SetNewId()
{
Id = CoreHelpers.GenerateComb();

View File

@@ -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");

View File

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

View File

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

View File

@@ -581,7 +581,8 @@ public class UserService : UserManager<User>, 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");

View File

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