diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3b96eeb468..8f125b7811 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -317,6 +317,8 @@ jobs:
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
+ sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
+ ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
upload:
name: Upload
diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml
index 1fa5c9587c..fe88782e35 100644
--- a/.github/workflows/scan.yml
+++ b/.github/workflows/scan.yml
@@ -49,6 +49,8 @@ jobs:
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
sarif_file: cx_result.sarif
+ sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
+ ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
quality:
name: Quality scan
diff --git a/Directory.Build.props b/Directory.Build.props
index cbe9786d65..2ede6ad8d1 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -3,7 +3,7 @@
net8.0
- 2025.3.2
+ 2025.3.3
Bit.$(MSBuildProjectName)
enable
diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
index 8a5d4f9009..9809e09141 100644
--- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
+++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
@@ -8,6 +8,8 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
@@ -55,8 +57,10 @@ public class OrganizationUsersController : Controller
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
+ private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient;
+ private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
public OrganizationUsersController(
IOrganizationRepository organizationRepository,
@@ -79,8 +83,10 @@ public class OrganizationUsersController : Controller
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand,
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
+ IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService,
- IPricingClient pricingClient)
+ IPricingClient pricingClient,
+ IConfirmOrganizationUserCommand confirmOrganizationUserCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -102,8 +108,10 @@ public class OrganizationUsersController : Controller
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery;
+ _policyRequirementQuery = policyRequirementQuery;
_featureService = featureService;
_pricingClient = pricingClient;
+ _confirmOrganizationUserCommand = confirmOrganizationUserCommand;
}
[HttpGet("{id}")]
@@ -291,7 +299,7 @@ public class OrganizationUsersController : Controller
await _organizationService.InitPendingOrganization(user.Id, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName);
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
- await _organizationService.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id);
+ await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id);
}
[HttpPost("{organizationUserId}/accept")]
@@ -303,11 +311,13 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException();
}
- var useMasterPasswordPolicy = await ShouldHandleResetPasswordAsync(orgId);
+ var useMasterPasswordPolicy = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
+ ? (await _policyRequirementQuery.GetAsync(user.Id)).AutoEnrollEnabled(orgId)
+ : await ShouldHandleResetPasswordAsync(orgId);
if (useMasterPasswordPolicy && string.IsNullOrWhiteSpace(model.ResetPasswordKey))
{
- throw new BadRequestException(string.Empty, "Master Password reset is required, but not provided.");
+ throw new BadRequestException("Master Password reset is required, but not provided.");
}
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
@@ -345,7 +355,7 @@ public class OrganizationUsersController : Controller
}
var userId = _userService.GetProperUserId(User);
- var result = await _organizationService.ConfirmUserAsync(orgGuidId, new Guid(id), model.Key, userId.Value);
+ var result = await _confirmOrganizationUserCommand.ConfirmUserAsync(orgGuidId, new Guid(id), model.Key, userId.Value);
}
[HttpPost("confirm")]
@@ -359,7 +369,7 @@ public class OrganizationUsersController : Controller
}
var userId = _userService.GetProperUserId(User);
- var results = await _organizationService.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value);
+ var results = await _confirmOrganizationUserCommand.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value);
return new ListResponseModel(results.Select(r =>
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs
index 34da3de10c..9fa9cb6672 100644
--- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs
+++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs
@@ -16,6 +16,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
@@ -61,6 +63,7 @@ public class OrganizationsController : Controller
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand;
private readonly IOrganizationDeleteCommand _organizationDeleteCommand;
+ private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient;
public OrganizationsController(
@@ -84,6 +87,7 @@ public class OrganizationsController : Controller
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand,
IOrganizationDeleteCommand organizationDeleteCommand,
+ IPolicyRequirementQuery policyRequirementQuery,
IPricingClient pricingClient)
{
_organizationRepository = organizationRepository;
@@ -106,6 +110,7 @@ public class OrganizationsController : Controller
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_cloudOrganizationSignUpCommand = cloudOrganizationSignUpCommand;
_organizationDeleteCommand = organizationDeleteCommand;
+ _policyRequirementQuery = policyRequirementQuery;
_pricingClient = pricingClient;
}
@@ -163,8 +168,13 @@ public class OrganizationsController : Controller
throw new NotFoundException();
}
- var resetPasswordPolicy =
- await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
+ if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements))
+ {
+ var resetPasswordPolicyRequirement = await _policyRequirementQuery.GetAsync(user.Id);
+ return new OrganizationAutoEnrollStatusResponseModel(organization.Id, resetPasswordPolicyRequirement.AutoEnrollEnabled(organization.Id));
+ }
+
+ var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null)
{
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false);
@@ -172,6 +182,7 @@ public class OrganizationsController : Controller
var data = JsonSerializer.Deserialize(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase);
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false);
+
}
[HttpPost("")]
diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs
index 6c19049c49..2555a6fe2d 100644
--- a/src/Api/Auth/Controllers/AccountsController.cs
+++ b/src/Api/Auth/Controllers/AccountsController.cs
@@ -355,6 +355,7 @@ public class AccountsController : Controller
throw new BadRequestException(ModelState);
}
+ [Obsolete("Replaced by the safer rotate-user-account-keys endpoint.")]
[HttpPost("key")]
public async Task PostKey([FromBody] UpdateKeyRequestModel model)
{
diff --git a/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs b/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs
new file mode 100644
index 0000000000..ba57788cec
--- /dev/null
+++ b/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs
@@ -0,0 +1,66 @@
+#nullable enable
+
+using System.ComponentModel.DataAnnotations;
+using Bit.Core.Enums;
+using Bit.Core.KeyManagement.Models.Data;
+using Bit.Core.Utilities;
+
+namespace Bit.Api.Auth.Models.Request.Accounts;
+
+public class MasterPasswordUnlockDataModel : IValidatableObject
+{
+ public required KdfType KdfType { get; set; }
+ public required int KdfIterations { get; set; }
+ public int? KdfMemory { get; set; }
+ public int? KdfParallelism { get; set; }
+
+ [StrictEmailAddress]
+ [StringLength(256)]
+ public required string Email { get; set; }
+ [StringLength(300)]
+ public required string MasterKeyAuthenticationHash { get; set; }
+ [EncryptedString] public required string MasterKeyEncryptedUserKey { get; set; }
+ [StringLength(50)]
+ public string? MasterPasswordHint { get; set; }
+
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ if (KdfType == KdfType.PBKDF2_SHA256)
+ {
+ if (KdfMemory.HasValue || KdfParallelism.HasValue)
+ {
+ yield return new ValidationResult("KdfMemory and KdfParallelism must be null for PBKDF2_SHA256", new[] { nameof(KdfMemory), nameof(KdfParallelism) });
+ }
+ }
+ else if (KdfType == KdfType.Argon2id)
+ {
+ if (!KdfMemory.HasValue || !KdfParallelism.HasValue)
+ {
+ yield return new ValidationResult("KdfMemory and KdfParallelism must have values for Argon2id", new[] { nameof(KdfMemory), nameof(KdfParallelism) });
+ }
+ }
+ else
+ {
+ yield return new ValidationResult("Invalid KdfType", new[] { nameof(KdfType) });
+ }
+ }
+
+ public MasterPasswordUnlockData ToUnlockData()
+ {
+ var data = new MasterPasswordUnlockData
+ {
+ KdfType = KdfType,
+ KdfIterations = KdfIterations,
+ KdfMemory = KdfMemory,
+ KdfParallelism = KdfParallelism,
+
+ Email = Email,
+
+ MasterKeyAuthenticationHash = MasterKeyAuthenticationHash,
+ MasterKeyEncryptedUserKey = MasterKeyEncryptedUserKey,
+ MasterPasswordHint = MasterPasswordHint
+ };
+ return data;
+ }
+
+}
diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs
index b8d5e30949..85e0981f22 100644
--- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs
+++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs
@@ -1,10 +1,23 @@
#nullable enable
+using Bit.Api.AdminConsole.Models.Request.Organizations;
+using Bit.Api.Auth.Models.Request;
+using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Api.KeyManagement.Models.Requests;
+using Bit.Api.KeyManagement.Validators;
+using Bit.Api.Tools.Models.Request;
+using Bit.Api.Vault.Models.Request;
using Bit.Core;
+using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Models.Data;
+using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces;
+using Bit.Core.KeyManagement.Models.Data;
+using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Repositories;
using Bit.Core.Services;
+using Bit.Core.Tools.Entities;
+using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -19,18 +32,45 @@ public class AccountsKeyManagementController : Controller
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IRegenerateUserAsymmetricKeysCommand _regenerateUserAsymmetricKeysCommand;
private readonly IUserService _userService;
+ private readonly IRotateUserAccountKeysCommand _rotateUserAccountKeysCommand;
+ private readonly IRotationValidator, IEnumerable> _cipherValidator;
+ private readonly IRotationValidator, IEnumerable> _folderValidator;
+ private readonly IRotationValidator, IReadOnlyList> _sendValidator;
+ private readonly IRotationValidator, IEnumerable>
+ _emergencyAccessValidator;
+ private readonly IRotationValidator,
+ IReadOnlyList>
+ _organizationUserValidator;
+ private readonly IRotationValidator, IEnumerable>
+ _webauthnKeyValidator;
public AccountsKeyManagementController(IUserService userService,
IFeatureService featureService,
IOrganizationUserRepository organizationUserRepository,
IEmergencyAccessRepository emergencyAccessRepository,
- IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand)
+ IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand,
+ IRotateUserAccountKeysCommand rotateUserKeyCommandV2,
+ IRotationValidator, IEnumerable> cipherValidator,
+ IRotationValidator, IEnumerable> folderValidator,
+ IRotationValidator, IReadOnlyList> sendValidator,
+ IRotationValidator, IEnumerable>
+ emergencyAccessValidator,
+ IRotationValidator, IReadOnlyList>
+ organizationUserValidator,
+ IRotationValidator, IEnumerable> webAuthnKeyValidator)
{
_userService = userService;
_featureService = featureService;
_regenerateUserAsymmetricKeysCommand = regenerateUserAsymmetricKeysCommand;
_organizationUserRepository = organizationUserRepository;
_emergencyAccessRepository = emergencyAccessRepository;
+ _rotateUserAccountKeysCommand = rotateUserKeyCommandV2;
+ _cipherValidator = cipherValidator;
+ _folderValidator = folderValidator;
+ _sendValidator = sendValidator;
+ _emergencyAccessValidator = emergencyAccessValidator;
+ _organizationUserValidator = organizationUserValidator;
+ _webauthnKeyValidator = webAuthnKeyValidator;
}
[HttpPost("regenerate-keys")]
@@ -47,4 +87,45 @@ public class AccountsKeyManagementController : Controller
await _regenerateUserAsymmetricKeysCommand.RegenerateKeysAsync(request.ToUserAsymmetricKeys(user.Id),
usersOrganizationAccounts, designatedEmergencyAccess);
}
+
+
+ [HttpPost("rotate-user-account-keys")]
+ public async Task RotateUserAccountKeysAsync([FromBody] RotateUserAccountKeysAndDataRequestModel model)
+ {
+ var user = await _userService.GetUserByPrincipalAsync(User);
+ if (user == null)
+ {
+ throw new UnauthorizedAccessException();
+ }
+
+ var dataModel = new RotateUserAccountKeysData
+ {
+ OldMasterKeyAuthenticationHash = model.OldMasterKeyAuthenticationHash,
+
+ UserKeyEncryptedAccountPrivateKey = model.AccountKeys.UserKeyEncryptedAccountPrivateKey,
+ AccountPublicKey = model.AccountKeys.AccountPublicKey,
+
+ MasterPasswordUnlockData = model.AccountUnlockData.MasterPasswordUnlockData.ToUnlockData(),
+ EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData),
+ OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.AccountUnlockData.OrganizationAccountRecoveryUnlockData),
+ WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.AccountUnlockData.PasskeyUnlockData),
+
+ Ciphers = await _cipherValidator.ValidateAsync(user, model.AccountData.Ciphers),
+ Folders = await _folderValidator.ValidateAsync(user, model.AccountData.Folders),
+ Sends = await _sendValidator.ValidateAsync(user, model.AccountData.Sends),
+ };
+
+ var result = await _rotateUserAccountKeysCommand.RotateUserAccountKeysAsync(user, dataModel);
+ if (result.Succeeded)
+ {
+ return;
+ }
+
+ foreach (var error in result.Errors)
+ {
+ ModelState.AddModelError(string.Empty, error.Description);
+ }
+
+ throw new BadRequestException(ModelState);
+ }
}
diff --git a/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs b/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs
new file mode 100644
index 0000000000..7c7de4d210
--- /dev/null
+++ b/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs
@@ -0,0 +1,10 @@
+#nullable enable
+using Bit.Core.Utilities;
+
+namespace Bit.Api.KeyManagement.Models.Requests;
+
+public class AccountKeysRequestModel
+{
+ [EncryptedString] public required string UserKeyEncryptedAccountPrivateKey { get; set; }
+ public required string AccountPublicKey { get; set; }
+}
diff --git a/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs
new file mode 100644
index 0000000000..b0b19e2bd3
--- /dev/null
+++ b/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs
@@ -0,0 +1,13 @@
+#nullable enable
+using System.ComponentModel.DataAnnotations;
+
+namespace Bit.Api.KeyManagement.Models.Requests;
+
+public class RotateUserAccountKeysAndDataRequestModel
+{
+ [StringLength(300)]
+ public required string OldMasterKeyAuthenticationHash { get; set; }
+ public required UnlockDataRequestModel AccountUnlockData { get; set; }
+ public required AccountKeysRequestModel AccountKeys { get; set; }
+ public required AccountDataRequestModel AccountData { get; set; }
+}
diff --git a/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs
new file mode 100644
index 0000000000..5156e2a655
--- /dev/null
+++ b/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs
@@ -0,0 +1,16 @@
+#nullable enable
+using Bit.Api.AdminConsole.Models.Request.Organizations;
+using Bit.Api.Auth.Models.Request;
+using Bit.Api.Auth.Models.Request.Accounts;
+using Bit.Api.Auth.Models.Request.WebAuthn;
+
+namespace Bit.Api.KeyManagement.Models.Requests;
+
+public class UnlockDataRequestModel
+{
+ // All methods to get to the userkey
+ public required MasterPasswordUnlockDataModel MasterPasswordUnlockData { get; set; }
+ public required IEnumerable EmergencyAccessUnlockData { get; set; }
+ public required IEnumerable OrganizationAccountRecoveryUnlockData { get; set; }
+ public required IEnumerable PasskeyUnlockData { get; set; }
+}
diff --git a/src/Api/KeyManagement/Models/Requests/UserDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/UserDataRequestModel.cs
new file mode 100644
index 0000000000..f854d82bcc
--- /dev/null
+++ b/src/Api/KeyManagement/Models/Requests/UserDataRequestModel.cs
@@ -0,0 +1,12 @@
+#nullable enable
+using Bit.Api.Tools.Models.Request;
+using Bit.Api.Vault.Models.Request;
+
+namespace Bit.Api.KeyManagement.Models.Requests;
+
+public class AccountDataRequestModel
+{
+ public required IEnumerable Ciphers { get; set; }
+ public required IEnumerable Folders { get; set; }
+ public required IEnumerable Sends { get; set; }
+}
diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj
index 50e372791f..f32eccfe8c 100644
--- a/src/Billing/Billing.csproj
+++ b/src/Billing/Billing.csproj
@@ -3,8 +3,6 @@
bitwarden-Billing
false
-
- $(WarningsNotAsErrors);CS9113
diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs
new file mode 100644
index 0000000000..9bfe8f791e
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs
@@ -0,0 +1,186 @@
+using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
+using Bit.Core.AdminConsole.Services;
+using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
+using Bit.Core.Billing.Enums;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Exceptions;
+using Bit.Core.Platform.Push;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+
+namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
+
+public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
+{
+ private readonly IOrganizationRepository _organizationRepository;
+ private readonly IOrganizationUserRepository _organizationUserRepository;
+ private readonly IUserRepository _userRepository;
+ private readonly IEventService _eventService;
+ private readonly IMailService _mailService;
+ private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
+ private readonly IPushNotificationService _pushNotificationService;
+ private readonly IPushRegistrationService _pushRegistrationService;
+ private readonly IPolicyService _policyService;
+ private readonly IDeviceRepository _deviceRepository;
+
+ public ConfirmOrganizationUserCommand(
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IUserRepository userRepository,
+ IEventService eventService,
+ IMailService mailService,
+ ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
+ IPushNotificationService pushNotificationService,
+ IPushRegistrationService pushRegistrationService,
+ IPolicyService policyService,
+ IDeviceRepository deviceRepository)
+ {
+ _organizationRepository = organizationRepository;
+ _organizationUserRepository = organizationUserRepository;
+ _userRepository = userRepository;
+ _eventService = eventService;
+ _mailService = mailService;
+ _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
+ _pushNotificationService = pushNotificationService;
+ _pushRegistrationService = pushRegistrationService;
+ _policyService = policyService;
+ _deviceRepository = deviceRepository;
+ }
+
+ public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
+ Guid confirmingUserId)
+ {
+ var result = await ConfirmUsersAsync(
+ organizationId,
+ new Dictionary() { { organizationUserId, key } },
+ confirmingUserId);
+
+ if (!result.Any())
+ {
+ throw new BadRequestException("User not valid.");
+ }
+
+ var (orgUser, error) = result[0];
+ if (error != "")
+ {
+ throw new BadRequestException(error);
+ }
+ return orgUser;
+ }
+
+ public async Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys,
+ Guid confirmingUserId)
+ {
+ var selectedOrganizationUsers = await _organizationUserRepository.GetManyAsync(keys.Keys);
+ var validSelectedOrganizationUsers = selectedOrganizationUsers
+ .Where(u => u.Status == OrganizationUserStatusType.Accepted && u.OrganizationId == organizationId && u.UserId != null)
+ .ToList();
+
+ if (!validSelectedOrganizationUsers.Any())
+ {
+ return new List>();
+ }
+
+ var validSelectedUserIds = validSelectedOrganizationUsers.Select(u => u.UserId.Value).ToList();
+
+ var organization = await _organizationRepository.GetByIdAsync(organizationId);
+ var allUsersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validSelectedUserIds);
+ var users = await _userRepository.GetManyAsync(validSelectedUserIds);
+ var usersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(validSelectedUserIds);
+
+ var keyedFilteredUsers = validSelectedOrganizationUsers.ToDictionary(u => u.UserId.Value, u => u);
+ var keyedOrganizationUsers = allUsersOrgs.GroupBy(u => u.UserId.Value)
+ .ToDictionary(u => u.Key, u => u.ToList());
+
+ var succeededUsers = new List();
+ var result = new List>();
+
+ foreach (var user in users)
+ {
+ if (!keyedFilteredUsers.ContainsKey(user.Id))
+ {
+ continue;
+ }
+ var orgUser = keyedFilteredUsers[user.Id];
+ var orgUsers = keyedOrganizationUsers.GetValueOrDefault(user.Id, new List());
+ try
+ {
+ if (organization.PlanType == PlanType.Free && (orgUser.Type == OrganizationUserType.Admin
+ || orgUser.Type == OrganizationUserType.Owner))
+ {
+ // Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this.
+ var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(user.Id);
+ if (adminCount > 0)
+ {
+ throw new BadRequestException("User can only be an admin of one free organization.");
+ }
+ }
+
+ var twoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled;
+ await CheckPoliciesAsync(organizationId, user, orgUsers, twoFactorEnabled);
+ orgUser.Status = OrganizationUserStatusType.Confirmed;
+ orgUser.Key = keys[orgUser.Id];
+ orgUser.Email = null;
+
+ await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
+ await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager);
+ await DeleteAndPushUserRegistrationAsync(organizationId, user.Id);
+ succeededUsers.Add(orgUser);
+ result.Add(Tuple.Create(orgUser, ""));
+ }
+ catch (BadRequestException e)
+ {
+ result.Add(Tuple.Create(orgUser, e.Message));
+ }
+ }
+
+ await _organizationUserRepository.ReplaceManyAsync(succeededUsers);
+
+ return result;
+ }
+
+ private async Task CheckPoliciesAsync(Guid organizationId, User user,
+ ICollection userOrgs, bool twoFactorEnabled)
+ {
+ // Enforce Two Factor Authentication Policy for this organization
+ var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication))
+ .Any(p => p.OrganizationId == organizationId);
+ if (orgRequiresTwoFactor && !twoFactorEnabled)
+ {
+ throw new BadRequestException("User does not have two-step login enabled.");
+ }
+
+ var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId);
+ var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg);
+ var otherSingleOrgPolicies =
+ singleOrgPolicies.Where(p => p.OrganizationId != organizationId);
+ // Enforce Single Organization Policy for this organization
+ if (hasOtherOrgs && singleOrgPolicies.Any(p => p.OrganizationId == organizationId))
+ {
+ throw new BadRequestException("Cannot confirm this member to the organization until they leave or remove all other organizations.");
+ }
+ // Enforce Single Organization Policy of other organizations user is a member of
+ if (otherSingleOrgPolicies.Any())
+ {
+ throw new BadRequestException("Cannot confirm this member to the organization because they are in another organization which forbids it.");
+ }
+ }
+
+ private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, Guid userId)
+ {
+ var devices = await GetUserDeviceIdsAsync(userId);
+ await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices,
+ organizationId.ToString());
+ await _pushNotificationService.PushSyncOrgKeysAsync(userId);
+ }
+
+ private async Task> GetUserDeviceIdsAsync(Guid userId)
+ {
+ var devices = await _deviceRepository.GetManyByUserIdAsync(userId);
+ return devices
+ .Where(d => !string.IsNullOrWhiteSpace(d.PushToken))
+ .Select(d => d.Id.ToString());
+ }
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs
new file mode 100644
index 0000000000..302ee0901d
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs
@@ -0,0 +1,30 @@
+using Bit.Core.Entities;
+
+namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
+
+///
+/// Command to confirm organization users who have accepted their invitations.
+///
+public interface IConfirmOrganizationUserCommand
+{
+ ///
+ /// Confirms a single organization user who has accepted their invitation.
+ ///
+ /// The ID of the organization.
+ /// The ID of the organization user to confirm.
+ /// The encrypted organization key for the user.
+ /// The ID of the user performing the confirmation.
+ /// The confirmed organization user.
+ /// Thrown when the user is not valid or cannot be confirmed.
+ Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId);
+
+ ///
+ /// Confirms multiple organization users who have accepted their invitations.
+ ///
+ /// The ID of the organization.
+ /// A dictionary mapping organization user IDs to their encrypted organization keys.
+ /// The ID of the user performing the confirmation.
+ /// A list of tuples containing the organization user and an error message (if any).
+ Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys,
+ Guid confirmingUserId);
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs
new file mode 100644
index 0000000000..4feef1b088
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs
@@ -0,0 +1,46 @@
+using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+using Bit.Core.Enums;
+
+namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+
+///
+/// Policy requirements for the Account recovery administration policy.
+///
+public class ResetPasswordPolicyRequirement : IPolicyRequirement
+{
+ ///
+ /// List of Organization Ids that require automatic enrollment in password recovery.
+ ///
+ private IEnumerable _autoEnrollOrganizations;
+ public IEnumerable AutoEnrollOrganizations { init => _autoEnrollOrganizations = value; }
+
+ ///
+ /// Returns true if provided organizationId requires automatic enrollment in password recovery.
+ ///
+ public bool AutoEnrollEnabled(Guid organizationId)
+ {
+ return _autoEnrollOrganizations.Contains(organizationId);
+ }
+
+
+}
+
+public class ResetPasswordPolicyRequirementFactory : BasePolicyRequirementFactory
+{
+ public override PolicyType PolicyType => PolicyType.ResetPassword;
+
+ protected override bool ExemptProviders => false;
+
+ protected override IEnumerable ExemptRoles => [];
+
+ public override ResetPasswordPolicyRequirement Create(IEnumerable policyDetails)
+ {
+ var result = policyDetails
+ .Where(p => p.GetDataModel().AutoEnrollEnabled)
+ .Select(p => p.OrganizationId)
+ .ToHashSet();
+
+ return new ResetPasswordPolicyRequirement() { AutoEnrollOrganizations = result };
+ }
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs
index 6c698f9ffc..d386006ad2 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs
@@ -33,5 +33,6 @@ public static class PolicyServiceCollectionExtensions
{
services.AddScoped, DisableSendPolicyRequirementFactory>();
services.AddScoped, SendOptionsPolicyRequirementFactory>();
+ services.AddScoped, ResetPasswordPolicyRequirementFactory>();
}
}
diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs
index dacb2ab162..476fccb480 100644
--- a/src/Core/AdminConsole/Services/IOrganizationService.cs
+++ b/src/Core/AdminConsole/Services/IOrganizationService.cs
@@ -38,9 +38,6 @@ public interface IOrganizationService
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites);
Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId);
Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false);
- Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId);
- Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys,
- Guid confirmingUserId);
Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId);
Task ImportAsync(Guid organizationId, IEnumerable groups,
IEnumerable newUsers, IEnumerable removeUserExternalIds,
diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs
index 1b44eea496..ab5703eaa1 100644
--- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs
+++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs
@@ -6,6 +6,8 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums;
@@ -76,6 +78,7 @@ public class OrganizationService : IOrganizationService
private readonly IOrganizationBillingService _organizationBillingService;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPricingClient _pricingClient;
+ private readonly IPolicyRequirementQuery _policyRequirementQuery;
public OrganizationService(
IOrganizationRepository organizationRepository,
@@ -111,7 +114,8 @@ public class OrganizationService : IOrganizationService
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IOrganizationBillingService organizationBillingService,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
- IPricingClient pricingClient)
+ IPricingClient pricingClient,
+ IPolicyRequirementQuery policyRequirementQuery)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -147,6 +151,7 @@ public class OrganizationService : IOrganizationService
_organizationBillingService = organizationBillingService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient;
+ _policyRequirementQuery = policyRequirementQuery;
}
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
@@ -1122,98 +1127,6 @@ public class OrganizationService : IOrganizationService
);
}
- public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
- Guid confirmingUserId)
- {
- var result = await ConfirmUsersAsync(
- organizationId,
- new Dictionary() { { organizationUserId, key } },
- confirmingUserId);
-
- if (!result.Any())
- {
- throw new BadRequestException("User not valid.");
- }
-
- var (orgUser, error) = result[0];
- if (error != "")
- {
- throw new BadRequestException(error);
- }
- return orgUser;
- }
-
- public async Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys,
- Guid confirmingUserId)
- {
- var selectedOrganizationUsers = await _organizationUserRepository.GetManyAsync(keys.Keys);
- var validSelectedOrganizationUsers = selectedOrganizationUsers
- .Where(u => u.Status == OrganizationUserStatusType.Accepted && u.OrganizationId == organizationId && u.UserId != null)
- .ToList();
-
- if (!validSelectedOrganizationUsers.Any())
- {
- return new List>();
- }
-
- var validSelectedUserIds = validSelectedOrganizationUsers.Select(u => u.UserId.Value).ToList();
-
- var organization = await GetOrgById(organizationId);
- var allUsersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validSelectedUserIds);
- var users = await _userRepository.GetManyAsync(validSelectedUserIds);
- var usersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(validSelectedUserIds);
-
- var keyedFilteredUsers = validSelectedOrganizationUsers.ToDictionary(u => u.UserId.Value, u => u);
- var keyedOrganizationUsers = allUsersOrgs.GroupBy(u => u.UserId.Value)
- .ToDictionary(u => u.Key, u => u.ToList());
-
- var succeededUsers = new List();
- var result = new List>();
-
- foreach (var user in users)
- {
- if (!keyedFilteredUsers.ContainsKey(user.Id))
- {
- continue;
- }
- var orgUser = keyedFilteredUsers[user.Id];
- var orgUsers = keyedOrganizationUsers.GetValueOrDefault(user.Id, new List());
- try
- {
- if (organization.PlanType == PlanType.Free && (orgUser.Type == OrganizationUserType.Admin
- || orgUser.Type == OrganizationUserType.Owner))
- {
- // Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this.
- var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(user.Id);
- if (adminCount > 0)
- {
- throw new BadRequestException("User can only be an admin of one free organization.");
- }
- }
-
- var twoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled;
- await CheckPoliciesAsync(organizationId, user, orgUsers, twoFactorEnabled);
- orgUser.Status = OrganizationUserStatusType.Confirmed;
- orgUser.Key = keys[orgUser.Id];
- orgUser.Email = null;
-
- await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
- await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager);
- await DeleteAndPushUserRegistrationAsync(organizationId, user.Id);
- succeededUsers.Add(orgUser);
- result.Add(Tuple.Create(orgUser, ""));
- }
- catch (BadRequestException e)
- {
- result.Add(Tuple.Create(orgUser, e.Message));
- }
- }
-
- await _organizationUserRepository.ReplaceManyAsync(succeededUsers);
-
- return result;
- }
-
internal async Task<(bool canScale, string failureReason)> CanScaleAsync(
Organization organization,
int seatsToAdd)
@@ -1300,32 +1213,7 @@ public class OrganizationService : IOrganizationService
}
}
- private async Task CheckPoliciesAsync(Guid organizationId, User user,
- ICollection userOrgs, bool twoFactorEnabled)
- {
- // Enforce Two Factor Authentication Policy for this organization
- var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication))
- .Any(p => p.OrganizationId == organizationId);
- if (orgRequiresTwoFactor && !twoFactorEnabled)
- {
- throw new BadRequestException("User does not have two-step login enabled.");
- }
- var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId);
- var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg);
- var otherSingleOrgPolicies =
- singleOrgPolicies.Where(p => p.OrganizationId != organizationId);
- // Enforce Single Organization Policy for this organization
- if (hasOtherOrgs && singleOrgPolicies.Any(p => p.OrganizationId == organizationId))
- {
- throw new BadRequestException("Cannot confirm this member to the organization until they leave or remove all other organizations.");
- }
- // Enforce Single Organization Policy of other organizations user is a member of
- if (otherSingleOrgPolicies.Any())
- {
- throw new BadRequestException("Cannot confirm this member to the organization because they are in another organization which forbids it.");
- }
- }
public async Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId)
{
@@ -1353,13 +1241,25 @@ public class OrganizationService : IOrganizationService
}
// Block the user from withdrawal if auto enrollment is enabled
- if (resetPasswordKey == null && resetPasswordPolicy.Data != null)
+ if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements))
{
- var data = JsonSerializer.Deserialize(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase);
-
- if (data?.AutoEnrollEnabled ?? false)
+ var resetPasswordPolicyRequirement = await _policyRequirementQuery.GetAsync(userId);
+ if (resetPasswordKey == null && resetPasswordPolicyRequirement.AutoEnrollEnabled(organizationId))
{
- throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from Password Reset.");
+ throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from account recovery.");
+ }
+
+ }
+ else
+ {
+ if (resetPasswordKey == null && resetPasswordPolicy.Data != null)
+ {
+ var data = JsonSerializer.Deserialize(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase);
+
+ if (data?.AutoEnrollEnabled ?? false)
+ {
+ throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from account recovery.");
+ }
}
}
@@ -1623,15 +1523,6 @@ public class OrganizationService : IOrganizationService
await _groupRepository.UpdateUsersAsync(group.Id, users);
}
- private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, Guid userId)
- {
- var devices = await GetUserDeviceIdsAsync(userId);
- await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices,
- organizationId.ToString());
- await _pushNotificationService.PushSyncOrgKeysAsync(userId);
- }
-
-
private async Task> GetUserDeviceIdsAsync(Guid userId)
{
var devices = await _deviceRepository.GetManyByUserIdAsync(userId);
diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs
index df102c855f..16a0ef9805 100644
--- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs
+++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs
@@ -32,6 +32,7 @@ public static class UserServiceCollectionExtensions
public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings)
{
services.AddScoped();
+ services.AddScoped();
}
private static void AddUserPasswordCommands(this IServiceCollection services)
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 0ae9f1d8d7..e41391b173 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -168,6 +168,9 @@ public static class FeatureFlagKeys
public const string Argon2Default = "argon2-default";
public const string UsePricingService = "use-pricing-service";
public const string RecordInstallationLastActivityDate = "installation-last-activity-date";
+ public const string UserkeyRotationV2 = "userkey-rotation-v2";
+ public const string EnablePasswordManagerSyncAndroid = "enable-password-manager-sync-android";
+ public const string EnablePasswordManagerSynciOS = "enable-password-manager-sync-ios";
public const string AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner";
public const string SingleTapPasskeyCreation = "single-tap-passkey-creation";
public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication";
diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj
index 2a3edcdc00..ea72f3c785 100644
--- a/src/Core/Core.csproj
+++ b/src/Core/Core.csproj
@@ -4,7 +4,7 @@
false
bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml
- $(WarningsNotAsErrors);CS1570;CS1574;CS8602;CS9113;CS1998;CS8604
+ $(WarningsNotAsErrors);CS1570;CS1574;CS9113;CS1998
diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs
new file mode 100644
index 0000000000..0ddfc03190
--- /dev/null
+++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs
@@ -0,0 +1,34 @@
+#nullable enable
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+
+namespace Bit.Core.KeyManagement.Models.Data;
+
+public class MasterPasswordUnlockData
+{
+ public KdfType KdfType { get; set; }
+ public int KdfIterations { get; set; }
+ public int? KdfMemory { get; set; }
+ public int? KdfParallelism { get; set; }
+
+ public required string Email { get; set; }
+ public required string MasterKeyAuthenticationHash { get; set; }
+ public required string MasterKeyEncryptedUserKey { get; set; }
+ public string? MasterPasswordHint { get; set; }
+
+ public bool ValidateForUser(User user)
+ {
+ if (KdfType != user.Kdf || KdfMemory != user.KdfMemory || KdfParallelism != user.KdfParallelism || KdfIterations != user.KdfIterations)
+ {
+ return false;
+ }
+ else if (Email != user.Email)
+ {
+ return false;
+ }
+ else
+ {
+ return true;
+ }
+ }
+}
diff --git a/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs
new file mode 100644
index 0000000000..7cb1c273a3
--- /dev/null
+++ b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs
@@ -0,0 +1,28 @@
+using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Models.Data;
+using Bit.Core.Entities;
+using Bit.Core.Tools.Entities;
+using Bit.Core.Vault.Entities;
+
+namespace Bit.Core.KeyManagement.Models.Data;
+
+public class RotateUserAccountKeysData
+{
+ // Authentication for this requests
+ public string OldMasterKeyAuthenticationHash { get; set; }
+
+ // Other keys encrypted by the userkey
+ public string UserKeyEncryptedAccountPrivateKey { get; set; }
+ public string AccountPublicKey { get; set; }
+
+ // All methods to get to the userkey
+ public MasterPasswordUnlockData MasterPasswordUnlockData { get; set; }
+ public IEnumerable EmergencyAccesses { get; set; }
+ public IReadOnlyList OrganizationUsers { get; set; }
+ public IEnumerable WebAuthnKeys { get; set; }
+
+ // User vault data encrypted by the userkey
+ public IEnumerable Ciphers { get; set; }
+ public IEnumerable Folders { get; set; }
+ public IReadOnlyList Sends { get; set; }
+}
diff --git a/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs b/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs
new file mode 100644
index 0000000000..ec40e7031d
--- /dev/null
+++ b/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs
@@ -0,0 +1,20 @@
+using Bit.Core.Entities;
+using Bit.Core.KeyManagement.Models.Data;
+using Microsoft.AspNetCore.Identity;
+
+namespace Bit.Core.KeyManagement.UserKey;
+
+///
+/// Responsible for rotation of a user key and updating database with re-encrypted data
+///
+public interface IRotateUserAccountKeysCommand
+{
+ ///
+ /// Sets a new user key and updates all encrypted data.
+ ///
+ /// All necessary information for rotation. If data is not included, this will lead to the change being rejected.
+ /// An IdentityResult for verification of the master password hash
+ /// User must be provided.
+ /// User KDF settings and email must match the model provided settings.
+ Task RotateUserAccountKeysAsync(User user, RotateUserAccountKeysData model);
+}
diff --git a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs
new file mode 100644
index 0000000000..f4dcf31d5c
--- /dev/null
+++ b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs
@@ -0,0 +1,134 @@
+using Bit.Core.Auth.Repositories;
+using Bit.Core.Entities;
+using Bit.Core.KeyManagement.Models.Data;
+using Bit.Core.Platform.Push;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Bit.Core.Tools.Repositories;
+using Bit.Core.Vault.Repositories;
+using Microsoft.AspNetCore.Identity;
+
+namespace Bit.Core.KeyManagement.UserKey.Implementations;
+
+///
+public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
+{
+ private readonly IUserService _userService;
+ private readonly IUserRepository _userRepository;
+ private readonly ICipherRepository _cipherRepository;
+ private readonly IFolderRepository _folderRepository;
+ private readonly ISendRepository _sendRepository;
+ private readonly IEmergencyAccessRepository _emergencyAccessRepository;
+ private readonly IOrganizationUserRepository _organizationUserRepository;
+ private readonly IPushNotificationService _pushService;
+ private readonly IdentityErrorDescriber _identityErrorDescriber;
+ private readonly IWebAuthnCredentialRepository _credentialRepository;
+ private readonly IPasswordHasher _passwordHasher;
+
+ ///
+ /// Instantiates a new
+ ///
+ /// Master password hash validation
+ /// Updates user keys and re-encrypted data if needed
+ /// Provides a method to update re-encrypted cipher data
+ /// Provides a method to update re-encrypted folder data
+ /// Provides a method to update re-encrypted send data
+ /// Provides a method to update re-encrypted emergency access data
+ /// Provides a method to update re-encrypted organization user data
+ /// Hashes the new master password
+ /// Logs out user from other devices after successful rotation
+ /// Provides a password mismatch error if master password hash validation fails
+ /// Provides a method to update re-encrypted WebAuthn keys
+ public RotateUserAccountKeysCommand(IUserService userService, IUserRepository userRepository,
+ ICipherRepository cipherRepository, IFolderRepository folderRepository, ISendRepository sendRepository,
+ IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository,
+ IPasswordHasher passwordHasher,
+ IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository)
+ {
+ _userService = userService;
+ _userRepository = userRepository;
+ _cipherRepository = cipherRepository;
+ _folderRepository = folderRepository;
+ _sendRepository = sendRepository;
+ _emergencyAccessRepository = emergencyAccessRepository;
+ _organizationUserRepository = organizationUserRepository;
+ _pushService = pushService;
+ _identityErrorDescriber = errors;
+ _credentialRepository = credentialRepository;
+ _passwordHasher = passwordHasher;
+ }
+
+ ///
+ public async Task RotateUserAccountKeysAsync(User user, RotateUserAccountKeysData model)
+ {
+ if (user == null)
+ {
+ throw new ArgumentNullException(nameof(user));
+ }
+
+ if (!await _userService.CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash))
+ {
+ return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
+ }
+
+ var now = DateTime.UtcNow;
+ user.RevisionDate = user.AccountRevisionDate = now;
+ user.LastKeyRotationDate = now;
+ user.SecurityStamp = Guid.NewGuid().ToString();
+
+ if (
+ !model.MasterPasswordUnlockData.ValidateForUser(user)
+ )
+ {
+ throw new InvalidOperationException("The provided master password unlock data is not valid for this user.");
+ }
+ if (
+ model.AccountPublicKey != user.PublicKey
+ )
+ {
+ throw new InvalidOperationException("The provided account public key does not match the user's current public key, and changing the account asymmetric keypair is currently not supported during key rotation.");
+ }
+
+ user.Key = model.MasterPasswordUnlockData.MasterKeyEncryptedUserKey;
+ user.PrivateKey = model.UserKeyEncryptedAccountPrivateKey;
+ user.MasterPassword = _passwordHasher.HashPassword(user, model.MasterPasswordUnlockData.MasterKeyAuthenticationHash);
+ user.MasterPasswordHint = model.MasterPasswordUnlockData.MasterPasswordHint;
+
+ List saveEncryptedDataActions = new();
+ if (model.Ciphers.Any())
+ {
+ saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, model.Ciphers));
+ }
+
+ if (model.Folders.Any())
+ {
+ saveEncryptedDataActions.Add(_folderRepository.UpdateForKeyRotation(user.Id, model.Folders));
+ }
+
+ if (model.Sends.Any())
+ {
+ saveEncryptedDataActions.Add(_sendRepository.UpdateForKeyRotation(user.Id, model.Sends));
+ }
+
+ if (model.EmergencyAccesses.Any())
+ {
+ saveEncryptedDataActions.Add(
+ _emergencyAccessRepository.UpdateForKeyRotation(user.Id, model.EmergencyAccesses));
+ }
+
+ if (model.OrganizationUsers.Any())
+ {
+ saveEncryptedDataActions.Add(
+ _organizationUserRepository.UpdateForKeyRotation(user.Id, model.OrganizationUsers));
+ }
+
+ if (model.WebAuthnKeys.Any())
+ {
+ saveEncryptedDataActions.Add(_credentialRepository.UpdateKeysForRotationAsync(user.Id, model.WebAuthnKeys));
+ }
+
+ await _userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, saveEncryptedDataActions);
+ await _pushService.PushLogOutAsync(user.Id);
+ return IdentityResult.Success;
+ }
+}
diff --git a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs
index f5493e4503..f6c0921165 100644
--- a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs
+++ b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs
@@ -6,12 +6,14 @@ Launch the Bitwarden extension to review your at-risk passwords.
Review at-risk passwords ({{{ReviewPasswordsUrl}}})
-{{#if (eq (length AdminOwnerEmails) 1)}}
-This request was initiated by {{AdminOwnerEmails.[0]}}.
-{{else}}
-This request was initiated by
-{{#each AdminOwnerEmails}}
- {{#if @last}}and {{/if}}{{this}}{{#unless @last}}, {{/unless}}
-{{/each}}.
+{{#if AdminOwnerEmails.[0]}}
+ {{#if AdminOwnerEmails.[1]}}
+ This request was initiated by
+ {{#each AdminOwnerEmails}}
+ {{#if @last}}and {{/if}}{{this}}{{#unless @last}}, {{/unless}}
+ {{/each}}.
+ {{else}}
+ This request was initiated by {{AdminOwnerEmails.[0]}}.
+ {{/if}}
{{/if}}
{{/SecurityTasksHtmlLayout}}
diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs
index 232e04fbd0..e13a06f660 100644
--- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs
+++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs
@@ -116,6 +116,7 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
}
private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services)
diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs
index 040e6e1f49..0e59b9998f 100644
--- a/src/Core/Repositories/IUserRepository.cs
+++ b/src/Core/Repositories/IUserRepository.cs
@@ -32,5 +32,7 @@ public interface IUserRepository : IRepository
/// Registered database calls to update re-encrypted data.
Task UpdateUserKeyAndEncryptedDataAsync(User user,
IEnumerable updateDataActions);
+ Task UpdateUserKeyAndEncryptedDataV2Async(User user,
+ IEnumerable updateDataActions);
Task DeleteManyAsync(IEnumerable users);
}
diff --git a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj
index 19512670ce..c51af39824 100644
--- a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj
+++ b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj
@@ -2,7 +2,7 @@
- $(WarningsNotAsErrors);CS8618;CS4014
+ $(WarningsNotAsErrors);CS8618
diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs
index 227a7c03e5..28478a0c41 100644
--- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs
+++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs
@@ -254,6 +254,42 @@ public class UserRepository : Repository, IUserRepository
}
+ public async Task UpdateUserKeyAndEncryptedDataV2Async(
+ User user,
+ IEnumerable updateDataActions)
+ {
+ await using var connection = new SqlConnection(ConnectionString);
+ connection.Open();
+
+ await using var transaction = connection.BeginTransaction();
+ try
+ {
+ user.AccountRevisionDate = user.RevisionDate;
+
+ ProtectData(user);
+ await connection.ExecuteAsync(
+ $"[{Schema}].[{Table}_Update]",
+ user,
+ transaction: transaction,
+ commandType: CommandType.StoredProcedure);
+
+ // Update re-encrypted data
+ foreach (var action in updateDataActions)
+ {
+ await action(connection, transaction);
+ }
+ transaction.Commit();
+ }
+ catch
+ {
+ transaction.Rollback();
+ UnprotectData(user);
+ throw;
+ }
+ UnprotectData(user);
+ }
+
+
public async Task> GetManyAsync(IEnumerable ids)
{
using (var connection = new SqlConnection(ReadOnlyConnectionString))
@@ -295,6 +331,18 @@ public class UserRepository : Repository, IUserRepository
var originalKey = user.Key;
// Protect values
+ ProtectData(user);
+
+ // Save
+ await saveTask();
+
+ // Restore original values
+ user.MasterPassword = originalMasterPassword;
+ user.Key = originalKey;
+ }
+
+ private void ProtectData(User user)
+ {
if (!user.MasterPassword?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
{
user.MasterPassword = string.Concat(Constants.DatabaseFieldProtectedPrefix,
@@ -306,13 +354,6 @@ public class UserRepository : Repository, IUserRepository
user.Key = string.Concat(Constants.DatabaseFieldProtectedPrefix,
_dataProtector.Protect(user.Key!));
}
-
- // Save
- await saveTask();
-
- // Restore original values
- user.MasterPassword = originalMasterPassword;
- user.Key = originalKey;
}
private void UnprotectData(User? user)
diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs
index cbfefb6483..127646ed59 100644
--- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs
+++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs
@@ -170,6 +170,7 @@ public class UserRepository : Repository, IUserR
entity.SecurityStamp = user.SecurityStamp;
entity.Key = user.Key;
+
entity.PrivateKey = user.PrivateKey;
entity.LastKeyRotationDate = user.LastKeyRotationDate;
entity.AccountRevisionDate = user.AccountRevisionDate;
@@ -194,6 +195,52 @@ public class UserRepository : Repository, IUserR
}
+
+ public async Task UpdateUserKeyAndEncryptedDataV2Async(Core.Entities.User user,
+ IEnumerable updateDataActions)
+ {
+ using var scope = ServiceScopeFactory.CreateScope();
+ var dbContext = GetDatabaseContext(scope);
+
+ await using var transaction = await dbContext.Database.BeginTransactionAsync();
+
+ // Update user
+ var userEntity = await dbContext.Users.FindAsync(user.Id);
+ if (userEntity == null)
+ {
+ throw new ArgumentException("User not found", nameof(user));
+ }
+
+ userEntity.SecurityStamp = user.SecurityStamp;
+ userEntity.Key = user.Key;
+ userEntity.PrivateKey = user.PrivateKey;
+
+ userEntity.Kdf = user.Kdf;
+ userEntity.KdfIterations = user.KdfIterations;
+ userEntity.KdfMemory = user.KdfMemory;
+ userEntity.KdfParallelism = user.KdfParallelism;
+
+ userEntity.Email = user.Email;
+
+ userEntity.MasterPassword = user.MasterPassword;
+ userEntity.MasterPasswordHint = user.MasterPasswordHint;
+
+ userEntity.LastKeyRotationDate = user.LastKeyRotationDate;
+ userEntity.AccountRevisionDate = user.AccountRevisionDate;
+ userEntity.RevisionDate = user.RevisionDate;
+
+ await dbContext.SaveChangesAsync();
+
+ // Update re-encrypted data
+ foreach (var action in updateDataActions)
+ {
+ // connection and transaction aren't used in EF
+ await action();
+ }
+
+ await transaction.CommitAsync();
+ }
+
public async Task> GetManyAsync(IEnumerable ids)
{
using (var scope = ServiceScopeFactory.CreateScope())
diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs
index ec7ca37460..7c05e1d680 100644
--- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs
+++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs
@@ -2,12 +2,18 @@
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.KeyManagement.Models.Requests;
+using Bit.Api.Tools.Models.Request;
+using Bit.Api.Vault.Models;
+using Bit.Api.Vault.Models.Request;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Billing.Enums;
+using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
+using Bit.Core.Vault.Enums;
using Bit.Test.Common.AutoFixture.Attributes;
+using Microsoft.AspNetCore.Identity;
using Xunit;
namespace Bit.Api.IntegrationTest.KeyManagement.Controllers;
@@ -23,6 +29,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture _passwordHasher;
private string _ownerEmail = null!;
public AccountsKeyManagementControllerTests(ApiApplicationFactory factory)
@@ -35,6 +42,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture();
_emergencyAccessRepository = _factory.GetService();
_organizationUserRepository = _factory.GetService();
+ _passwordHasher = _factory.GetService>();
}
public async Task InitializeAsync()
@@ -161,4 +169,87 @@ public class AccountsKeyManagementControllerTests : IClassFixture(), Arg.Any())
.Returns(organizationUsers);
}
+
+ [Theory]
+ [BitAutoData]
+ public async Task Accept_WhenOrganizationUsePoliciesIsEnabledAndResetPolicyIsEnabled_WithPolicyRequirementsEnabled_ShouldHandleResetPassword(Guid orgId, Guid orgUserId,
+ OrganizationUserAcceptRequestModel model, User user, SutProvider sutProvider)
+ {
+ // Arrange
+ var applicationCacheService = sutProvider.GetDependency();
+ applicationCacheService.GetOrganizationAbilityAsync(orgId).Returns(new OrganizationAbility { UsePolicies = true });
+
+ sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
+
+ var policy = new Policy
+ {
+ Enabled = true,
+ Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }),
+ };
+ var userService = sutProvider.GetDependency();
+ userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
+
+ var policyRequirementQuery = sutProvider.GetDependency();
+
+ var policyRepository = sutProvider.GetDependency();
+
+ var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [orgId] };
+
+ policyRequirementQuery.GetAsync(user.Id).Returns(policyRequirement);
+
+ // Act
+ await sutProvider.Sut.Accept(orgId, orgUserId, model);
+
+ // Assert
+ await sutProvider.GetDependency().Received(1)
+ .AcceptOrgUserByEmailTokenAsync(orgUserId, user, model.Token, userService);
+ await sutProvider.GetDependency().Received(1)
+ .UpdateUserResetPasswordEnrollmentAsync(orgId, user.Id, model.ResetPasswordKey, user.Id);
+
+ await userService.Received(1).GetUserByPrincipalAsync(default);
+ await applicationCacheService.Received(0).GetOrganizationAbilityAsync(orgId);
+ await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
+ await policyRequirementQuery.Received(1).GetAsync(user.Id);
+ Assert.True(policyRequirement.AutoEnrollEnabled(orgId));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task Accept_WithInvalidModelResetPasswordKey_WithPolicyRequirementsEnabled_ThrowsBadRequestException(Guid orgId, Guid orgUserId,
+ OrganizationUserAcceptRequestModel model, User user, SutProvider sutProvider)
+ {
+ // Arrange
+ model.ResetPasswordKey = " ";
+ var applicationCacheService = sutProvider.GetDependency();
+ applicationCacheService.GetOrganizationAbilityAsync(orgId).Returns(new OrganizationAbility { UsePolicies = true });
+
+ sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
+
+ var policy = new Policy
+ {
+ Enabled = true,
+ Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }),
+ };
+ var userService = sutProvider.GetDependency();
+ userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
+
+ var policyRepository = sutProvider.GetDependency();
+
+ var policyRequirementQuery = sutProvider.GetDependency();
+
+ var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [orgId] };
+
+ policyRequirementQuery.GetAsync(user.Id).Returns(policyRequirement);
+
+ // Act
+ var exception = await Assert.ThrowsAsync(() =>
+ sutProvider.Sut.Accept(orgId, orgUserId, model));
+
+ // Assert
+ await sutProvider.GetDependency().Received(0)
+ .AcceptOrgUserByEmailTokenAsync(orgUserId, user, model.Token, userService);
+ await sutProvider.GetDependency().Received(0)
+ .UpdateUserResetPasswordEnrollmentAsync(orgId, user.Id, model.ResetPasswordKey, user.Id);
+
+ await userService.Received(1).GetUserByPrincipalAsync(default);
+ await applicationCacheService.Received(0).GetOrganizationAbilityAsync(orgId);
+ await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
+ await policyRequirementQuery.Received(1).GetAsync(user.Id);
+
+ Assert.Equal("Master Password reset is required, but not provided.", exception.Message);
+ }
}
diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs
index b0906ddc43..8e6d2ce27b 100644
--- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs
+++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs
@@ -4,12 +4,15 @@ using Bit.Api.AdminConsole.Controllers;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
+using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
@@ -55,6 +58,7 @@ public class OrganizationsControllerTests : IDisposable
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand;
private readonly IOrganizationDeleteCommand _organizationDeleteCommand;
+ private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient;
private readonly OrganizationsController _sut;
@@ -80,6 +84,7 @@ public class OrganizationsControllerTests : IDisposable
_removeOrganizationUserCommand = Substitute.For();
_cloudOrganizationSignUpCommand = Substitute.For();
_organizationDeleteCommand = Substitute.For();
+ _policyRequirementQuery = Substitute.For();
_pricingClient = Substitute.For();
_sut = new OrganizationsController(
@@ -103,6 +108,7 @@ public class OrganizationsControllerTests : IDisposable
_removeOrganizationUserCommand,
_cloudOrganizationSignUpCommand,
_organizationDeleteCommand,
+ _policyRequirementQuery,
_pricingClient);
}
@@ -236,4 +242,55 @@ public class OrganizationsControllerTests : IDisposable
await _organizationDeleteCommand.Received(1).DeleteAsync(organization);
}
+
+ [Theory, AutoData]
+ public async Task GetAutoEnrollStatus_WithPolicyRequirementsEnabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue(
+ User user,
+ Organization organization,
+ OrganizationUser organizationUser
+ )
+ {
+ var policyRequirement = new ResetPasswordPolicyRequirement() { AutoEnrollOrganizations = [organization.Id] };
+
+ _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user);
+ _organizationRepository.GetByIdentifierAsync(organization.Id.ToString()).Returns(organization);
+ _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
+ _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser);
+ _policyRequirementQuery.GetAsync(user.Id).Returns(policyRequirement);
+
+ var result = await _sut.GetAutoEnrollStatus(organization.Id.ToString());
+
+ await _userService.Received(1).GetUserByPrincipalAsync(Arg.Any());
+ await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString());
+ await _policyRequirementQuery.Received(1).GetAsync(user.Id);
+
+ Assert.True(result.ResetPasswordEnabled);
+ Assert.Equal(result.Id, organization.Id);
+ }
+
+ [Theory, AutoData]
+ public async Task GetAutoEnrollStatus_WithPolicyRequirementsDisabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue(
+ User user,
+ Organization organization,
+ OrganizationUser organizationUser
+)
+ {
+
+ var policy = new Policy() { Type = PolicyType.ResetPassword, Enabled = true, Data = "{\"AutoEnrollEnabled\": true}", OrganizationId = organization.Id };
+
+ _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user);
+ _organizationRepository.GetByIdentifierAsync(organization.Id.ToString()).Returns(organization);
+ _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false);
+ _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser);
+ _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword).Returns(policy);
+
+ var result = await _sut.GetAutoEnrollStatus(organization.Id.ToString());
+
+ await _userService.Received(1).GetUserByPrincipalAsync(Arg.Any());
+ await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString());
+ await _policyRequirementQuery.Received(0).GetAsync(user.Id);
+ await _policyRepository.Received(1).GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
+
+ Assert.True(result.ResetPasswordEnabled);
+ }
}
diff --git a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs
index 2615697ad3..49c4f88cb4 100644
--- a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs
+++ b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs
@@ -1,17 +1,28 @@
#nullable enable
using System.Security.Claims;
+using Bit.Api.AdminConsole.Models.Request.Organizations;
+using Bit.Api.Auth.Models.Request;
+using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Api.KeyManagement.Controllers;
using Bit.Api.KeyManagement.Models.Requests;
+using Bit.Api.KeyManagement.Validators;
+using Bit.Api.Tools.Models.Request;
+using Bit.Api.Vault.Models.Request;
using Bit.Core;
+using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Data;
+using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Repositories;
using Bit.Core.Services;
+using Bit.Core.Tools.Entities;
+using Bit.Core.Vault.Entities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
+using Microsoft.AspNetCore.Identity;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
@@ -93,4 +104,78 @@ public class AccountsKeyManagementControllerTests
Arg.Is(orgUsers),
Arg.Is(accessDetails));
}
+
+ [Theory]
+ [BitAutoData]
+ public async Task RotateUserAccountKeysSuccess(SutProvider sutProvider,
+ RotateUserAccountKeysAndDataRequestModel data, User user)
+ {
+ sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user);
+ sutProvider.GetDependency().RotateUserAccountKeysAsync(Arg.Any(), Arg.Any())
+ .Returns(IdentityResult.Success);
+ await sutProvider.Sut.RotateUserAccountKeysAsync(data);
+
+ await sutProvider.GetDependency, IEnumerable>>().Received(1)
+ .ValidateAsync(Arg.Any(), Arg.Is(data.AccountUnlockData.EmergencyAccessUnlockData));
+ await sutProvider.GetDependency, IReadOnlyList>>().Received(1)
+ .ValidateAsync(Arg.Any(), Arg.Is(data.AccountUnlockData.OrganizationAccountRecoveryUnlockData));
+ await sutProvider.GetDependency, IEnumerable>>().Received(1)
+ .ValidateAsync(Arg.Any(), Arg.Is(data.AccountUnlockData.PasskeyUnlockData));
+
+ await sutProvider.GetDependency, IEnumerable>>().Received(1)
+ .ValidateAsync(Arg.Any(), Arg.Is(data.AccountData.Ciphers));
+ await sutProvider.GetDependency, IEnumerable>>().Received(1)
+ .ValidateAsync(Arg.Any(), Arg.Is(data.AccountData.Folders));
+ await sutProvider.GetDependency, IReadOnlyList>>().Received(1)
+ .ValidateAsync(Arg.Any(), Arg.Is(data.AccountData.Sends));
+
+ await sutProvider.GetDependency().Received(1)
+ .RotateUserAccountKeysAsync(Arg.Is(user), Arg.Is(d =>
+ d.OldMasterKeyAuthenticationHash == data.OldMasterKeyAuthenticationHash
+
+ && d.MasterPasswordUnlockData.KdfType == data.AccountUnlockData.MasterPasswordUnlockData.KdfType
+ && d.MasterPasswordUnlockData.KdfIterations == data.AccountUnlockData.MasterPasswordUnlockData.KdfIterations
+ && d.MasterPasswordUnlockData.KdfMemory == data.AccountUnlockData.MasterPasswordUnlockData.KdfMemory
+ && d.MasterPasswordUnlockData.KdfParallelism == data.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism
+ && d.MasterPasswordUnlockData.Email == data.AccountUnlockData.MasterPasswordUnlockData.Email
+
+ && d.MasterPasswordUnlockData.MasterKeyAuthenticationHash == data.AccountUnlockData.MasterPasswordUnlockData.MasterKeyAuthenticationHash
+ && d.MasterPasswordUnlockData.MasterKeyEncryptedUserKey == data.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey
+
+ && d.AccountPublicKey == data.AccountKeys.AccountPublicKey
+ && d.UserKeyEncryptedAccountPrivateKey == data.AccountKeys.UserKeyEncryptedAccountPrivateKey
+ ));
+ }
+
+
+ [Theory]
+ [BitAutoData]
+ public async Task RotateUserKeyNoUser_Throws(SutProvider sutProvider,
+ RotateUserAccountKeysAndDataRequestModel data)
+ {
+ User? user = null;
+ sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user);
+ sutProvider.GetDependency().RotateUserAccountKeysAsync(Arg.Any(), Arg.Any())
+ .Returns(IdentityResult.Success);
+ await Assert.ThrowsAsync(() => sutProvider.Sut.RotateUserAccountKeysAsync(data));
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task RotateUserKeyWrongData_Throws(SutProvider sutProvider,
+ RotateUserAccountKeysAndDataRequestModel data, User user, IdentityErrorDescriber _identityErrorDescriber)
+ {
+ sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user);
+ sutProvider.GetDependency().RotateUserAccountKeysAsync(Arg.Any(), Arg.Any())
+ .Returns(IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()));
+ try
+ {
+ await sutProvider.Sut.RotateUserAccountKeysAsync(data);
+ Assert.Fail("Should have thrown");
+ }
+ catch (BadRequestException ex)
+ {
+ Assert.NotEmpty(ex.ModelState.Values);
+ }
+ }
}
diff --git a/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs b/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs
new file mode 100644
index 0000000000..4c78c7015a
--- /dev/null
+++ b/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs
@@ -0,0 +1,68 @@
+#nullable enable
+using System.ComponentModel.DataAnnotations;
+using Bit.Api.Auth.Models.Request.Accounts;
+using Bit.Core.Enums;
+using Xunit;
+
+namespace Bit.Api.Test.KeyManagement.Models.Request;
+
+public class MasterPasswordUnlockDataModelTests
+{
+
+ readonly string _mockEncryptedString = "2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg=";
+
+ [Theory]
+ [InlineData(KdfType.PBKDF2_SHA256, 5000, null, null)]
+ [InlineData(KdfType.PBKDF2_SHA256, 100000, null, null)]
+ [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)]
+ [InlineData(KdfType.Argon2id, 3, 64, 4)]
+ public void Validate_Success(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism)
+ {
+ var model = new MasterPasswordUnlockDataModel
+ {
+ KdfType = kdfType,
+ KdfIterations = kdfIterations,
+ KdfMemory = kdfMemory,
+ KdfParallelism = kdfParallelism,
+ Email = "example@example.com",
+ MasterKeyAuthenticationHash = "hash",
+ MasterKeyEncryptedUserKey = _mockEncryptedString,
+ MasterPasswordHint = "hint"
+ };
+ var result = Validate(model);
+ Assert.Empty(result);
+ }
+
+ [Theory]
+ [InlineData(KdfType.Argon2id, 1, null, 1)]
+ [InlineData(KdfType.Argon2id, 1, 64, null)]
+ [InlineData(KdfType.PBKDF2_SHA256, 5000, 0, null)]
+ [InlineData(KdfType.PBKDF2_SHA256, 5000, null, 0)]
+ [InlineData(KdfType.PBKDF2_SHA256, 5000, 0, 0)]
+ [InlineData((KdfType)2, 100000, null, null)]
+ [InlineData((KdfType)2, 2, 64, 4)]
+ public void Validate_Failure(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism)
+ {
+ var model = new MasterPasswordUnlockDataModel
+ {
+ KdfType = kdfType,
+ KdfIterations = kdfIterations,
+ KdfMemory = kdfMemory,
+ KdfParallelism = kdfParallelism,
+ Email = "example@example.com",
+ MasterKeyAuthenticationHash = "hash",
+ MasterKeyEncryptedUserKey = _mockEncryptedString,
+ MasterPasswordHint = "hint"
+ };
+ var result = Validate(model);
+ Assert.Single(result);
+ Assert.NotNull(result.First().ErrorMessage);
+ }
+
+ private static List Validate(MasterPasswordUnlockDataModel model)
+ {
+ var results = new List();
+ Validator.TryValidateObject(model, new ValidationContext(model), results, true);
+ return results;
+ }
+}
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs
new file mode 100644
index 0000000000..06335f668d
--- /dev/null
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs
@@ -0,0 +1,324 @@
+using Bit.Core.AdminConsole.Entities;
+using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
+using Bit.Core.AdminConsole.Services;
+using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
+using Bit.Core.Billing.Enums;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Exceptions;
+using Bit.Core.Models.Data.Organizations.OrganizationUsers;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Bit.Core.Test.AdminConsole.AutoFixture;
+using Bit.Core.Test.AutoFixture.OrganizationUserFixtures;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers;
+
+[SutProviderCustomize]
+public class ConfirmOrganizationUserCommandTests
+{
+ [Theory, BitAutoData]
+ public async Task ConfirmUserAsync_WithInvalidStatus_ThrowsBadRequestException(OrganizationUser confirmingUser,
+ [OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser orgUser, string key,
+ SutProvider sutProvider)
+ {
+ var organizationUserRepository = sutProvider.GetDependency();
+
+ organizationUserRepository.GetByIdAsync(orgUser.Id).Returns(orgUser);
+
+ var exception = await Assert.ThrowsAsync(
+ () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id));
+ Assert.Contains("User not valid.", exception.Message);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ConfirmUserAsync_WithWrongOrganization_ThrowsBadRequestException(OrganizationUser confirmingUser,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, string key,
+ SutProvider sutProvider)
+ {
+ var organizationUserRepository = sutProvider.GetDependency();
+
+ organizationUserRepository.GetByIdAsync(orgUser.Id).Returns(orgUser);
+
+ var exception = await Assert.ThrowsAsync(
+ () => sutProvider.Sut.ConfirmUserAsync(confirmingUser.OrganizationId, orgUser.Id, key, confirmingUser.Id));
+ Assert.Contains("User not valid.", exception.Message);
+ }
+
+ [Theory]
+ [BitAutoData(OrganizationUserType.Admin)]
+ [BitAutoData(OrganizationUserType.Owner)]
+ public async Task ConfirmUserAsync_ToFree_WithExistingAdminOrOwner_ThrowsBadRequestException(OrganizationUserType userType, Organization org, OrganizationUser confirmingUser,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
+ string key, SutProvider sutProvider)
+ {
+ var organizationUserRepository = sutProvider.GetDependency();
+ var organizationRepository = sutProvider.GetDependency();
+ var userRepository = sutProvider.GetDependency();
+
+ org.PlanType = PlanType.Free;
+ orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
+ orgUser.UserId = user.Id;
+ orgUser.Type = userType;
+ organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
+ organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(orgUser.UserId.Value).Returns(1);
+ organizationRepository.GetByIdAsync(org.Id).Returns(org);
+ userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
+
+ var exception = await Assert.ThrowsAsync(
+ () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id));
+ Assert.Contains("User can only be an admin of one free organization.", exception.Message);
+ }
+
+ [Theory]
+ [BitAutoData(PlanType.Custom, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.Custom, OrganizationUserType.Owner)]
+ [BitAutoData(PlanType.EnterpriseAnnually, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.EnterpriseAnnually, OrganizationUserType.Owner)]
+ [BitAutoData(PlanType.EnterpriseAnnually2020, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.EnterpriseAnnually2020, OrganizationUserType.Owner)]
+ [BitAutoData(PlanType.EnterpriseAnnually2019, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.EnterpriseAnnually2019, OrganizationUserType.Owner)]
+ [BitAutoData(PlanType.EnterpriseMonthly, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.EnterpriseMonthly, OrganizationUserType.Owner)]
+ [BitAutoData(PlanType.EnterpriseMonthly2020, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.EnterpriseMonthly2020, OrganizationUserType.Owner)]
+ [BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Owner)]
+ [BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Owner)]
+ [BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Owner)]
+ [BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Owner)]
+ [BitAutoData(PlanType.TeamsAnnually2020, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.TeamsAnnually2020, OrganizationUserType.Owner)]
+ [BitAutoData(PlanType.TeamsAnnually2019, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.TeamsAnnually2019, OrganizationUserType.Owner)]
+ [BitAutoData(PlanType.TeamsMonthly, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.TeamsMonthly, OrganizationUserType.Owner)]
+ [BitAutoData(PlanType.TeamsMonthly2020, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.TeamsMonthly2020, OrganizationUserType.Owner)]
+ [BitAutoData(PlanType.TeamsMonthly2019, OrganizationUserType.Admin)]
+ [BitAutoData(PlanType.TeamsMonthly2019, OrganizationUserType.Owner)]
+ public async Task ConfirmUserAsync_ToNonFree_WithExistingFreeAdminOrOwner_Succeeds(PlanType planType, OrganizationUserType orgUserType, Organization org, OrganizationUser confirmingUser,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
+ string key, SutProvider sutProvider)
+ {
+ var organizationUserRepository = sutProvider.GetDependency();
+ var organizationRepository = sutProvider.GetDependency();
+ var userRepository = sutProvider.GetDependency();
+
+ org.PlanType = planType;
+ orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
+ orgUser.UserId = user.Id;
+ orgUser.Type = orgUserType;
+ orgUser.AccessSecretsManager = false;
+ organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
+ organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(orgUser.UserId.Value).Returns(1);
+ organizationRepository.GetByIdAsync(org.Id).Returns(org);
+ userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
+
+ await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id);
+
+ await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
+ await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email);
+ await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1));
+ }
+
+
+ [Theory, BitAutoData]
+ public async Task ConfirmUserAsync_AsUser_WithSingleOrgPolicyAppliedFromConfirmingOrg_ThrowsBadRequestException(Organization org, OrganizationUser confirmingUser,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
+ OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy,
+ string key, SutProvider sutProvider)
+ {
+ var organizationUserRepository = sutProvider.GetDependency();
+ var organizationRepository = sutProvider.GetDependency();
+ var userRepository = sutProvider.GetDependency();
+ var policyService = sutProvider.GetDependency();
+
+ org.PlanType = PlanType.EnterpriseAnnually;
+ orgUser.Status = OrganizationUserStatusType.Accepted;
+ orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
+ orgUser.UserId = orgUserAnotherOrg.UserId = user.Id;
+ organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
+ organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg });
+ organizationRepository.GetByIdAsync(org.Id).Returns(org);
+ userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
+ singleOrgPolicy.OrganizationId = org.Id;
+ policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy });
+
+ var exception = await Assert.ThrowsAsync(
+ () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id));
+ Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", exception.Message);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ConfirmUserAsync_AsUser_WithSingleOrgPolicyAppliedFromOtherOrg_ThrowsBadRequestException(Organization org, OrganizationUser confirmingUser,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
+ OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy,
+ string key, SutProvider sutProvider)
+ {
+ var organizationUserRepository = sutProvider.GetDependency();
+ var organizationRepository = sutProvider.GetDependency();
+ var userRepository = sutProvider.GetDependency();
+ var policyService = sutProvider.GetDependency();
+
+ org.PlanType = PlanType.EnterpriseAnnually;
+ orgUser.Status = OrganizationUserStatusType.Accepted;
+ orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
+ orgUser.UserId = orgUserAnotherOrg.UserId = user.Id;
+ organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
+ organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg });
+ organizationRepository.GetByIdAsync(org.Id).Returns(org);
+ userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
+ singleOrgPolicy.OrganizationId = orgUserAnotherOrg.Id;
+ policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy });
+
+ var exception = await Assert.ThrowsAsync(
+ () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id));
+ Assert.Contains("Cannot confirm this member to the organization because they are in another organization which forbids it.", exception.Message);
+ }
+
+ [Theory]
+ [BitAutoData(OrganizationUserType.Admin)]
+ [BitAutoData(OrganizationUserType.Owner)]
+ public async Task ConfirmUserAsync_AsOwnerOrAdmin_WithSingleOrgPolicy_ExcludedViaUserType_Success(
+ OrganizationUserType userType, Organization org, OrganizationUser confirmingUser,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
+ OrganizationUser orgUserAnotherOrg,
+ string key, SutProvider sutProvider)
+ {
+ var organizationUserRepository = sutProvider.GetDependency();
+ var organizationRepository = sutProvider.GetDependency();
+ var userRepository = sutProvider.GetDependency();
+
+ org.PlanType = PlanType.EnterpriseAnnually;
+ orgUser.Type = userType;
+ orgUser.Status = OrganizationUserStatusType.Accepted;
+ orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
+ orgUser.UserId = orgUserAnotherOrg.UserId = user.Id;
+ orgUser.AccessSecretsManager = true;
+ organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
+ organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg });
+ organizationRepository.GetByIdAsync(org.Id).Returns(org);
+ userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
+
+ await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id);
+
+ await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
+ await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, true);
+ await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ConfirmUserAsync_WithTwoFactorPolicyAndTwoFactorDisabled_ThrowsBadRequestException(Organization org, OrganizationUser confirmingUser,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
+ OrganizationUser orgUserAnotherOrg,
+ [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy,
+ string key, SutProvider sutProvider)
+ {
+ var organizationUserRepository = sutProvider.GetDependency();
+ var organizationRepository = sutProvider.GetDependency();
+ var userRepository = sutProvider.GetDependency();
+ var policyService = sutProvider.GetDependency();
+ var twoFactorIsEnabledQuery = sutProvider.GetDependency();
+
+ org.PlanType = PlanType.EnterpriseAnnually;
+ orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
+ orgUser.UserId = orgUserAnotherOrg.UserId = user.Id;
+ organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
+ organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg });
+ organizationRepository.GetByIdAsync(org.Id).Returns(org);
+ userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
+ twoFactorPolicy.OrganizationId = org.Id;
+ policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy });
+ twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id)))
+ .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, false) });
+
+ var exception = await Assert.ThrowsAsync(
+ () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id));
+ Assert.Contains("User does not have two-step login enabled.", exception.Message);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ConfirmUserAsync_WithTwoFactorPolicyAndTwoFactorEnabled_Succeeds(Organization org, OrganizationUser confirmingUser,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
+ [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy,
+ string key, SutProvider sutProvider)
+ {
+ var organizationUserRepository = sutProvider.GetDependency();
+ var organizationRepository = sutProvider.GetDependency();
+ var userRepository = sutProvider.GetDependency();
+ var policyService = sutProvider.GetDependency();
+ var twoFactorIsEnabledQuery = sutProvider.GetDependency();
+
+ org.PlanType = PlanType.EnterpriseAnnually;
+ orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
+ orgUser.UserId = user.Id;
+ organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
+ organizationRepository.GetByIdAsync(org.Id).Returns(org);
+ userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
+ twoFactorPolicy.OrganizationId = org.Id;
+ policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy });
+ twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id)))
+ .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, true) });
+
+ await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ConfirmUsersAsync_WithMultipleUsers_ReturnsExpectedMixedResults(Organization org,
+ OrganizationUser confirmingUser,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser1,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2,
+ [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3,
+ OrganizationUser anotherOrgUser, User user1, User user2, User user3,
+ [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy,
+ [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy,
+ string key, SutProvider sutProvider)
+ {
+ var organizationUserRepository = sutProvider.GetDependency();
+ var organizationRepository = sutProvider.GetDependency();
+ var userRepository = sutProvider.GetDependency();
+ var policyService = sutProvider.GetDependency();
+ var twoFactorIsEnabledQuery = sutProvider.GetDependency();
+
+ org.PlanType = PlanType.EnterpriseAnnually;
+ orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = confirmingUser.OrganizationId = org.Id;
+ orgUser1.UserId = user1.Id;
+ orgUser2.UserId = user2.Id;
+ orgUser3.UserId = user3.Id;
+ anotherOrgUser.UserId = user3.Id;
+ var orgUsers = new[] { orgUser1, orgUser2, orgUser3 };
+ organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(orgUsers);
+ organizationRepository.GetByIdAsync(org.Id).Returns(org);
+ userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user1, user2, user3 });
+ twoFactorPolicy.OrganizationId = org.Id;
+ policyService.GetPoliciesApplicableToUserAsync(Arg.Any(), PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy });
+ twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id) && ids.Contains(user3.Id)))
+ .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>()
+ {
+ (user1.Id, true),
+ (user2.Id, false),
+ (user3.Id, true)
+ });
+ singleOrgPolicy.OrganizationId = org.Id;
+ policyService.GetPoliciesApplicableToUserAsync(user3.Id, PolicyType.SingleOrg)
+ .Returns(new[] { singleOrgPolicy });
+ organizationUserRepository.GetManyByManyUsersAsync(default)
+ .ReturnsForAnyArgs(new[] { orgUser1, orgUser2, orgUser3, anotherOrgUser });
+
+ var keys = orgUsers.ToDictionary(ou => ou.Id, _ => key);
+ var result = await sutProvider.Sut.ConfirmUsersAsync(confirmingUser.OrganizationId, keys, confirmingUser.Id);
+ Assert.Contains("", result[0].Item2);
+ Assert.Contains("User does not have two-step login enabled.", result[1].Item2);
+ Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", result[2].Item2);
+ }
+}
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirementFactoryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirementFactoryTests.cs
new file mode 100644
index 0000000000..181f4f170e
--- /dev/null
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirementFactoryTests.cs
@@ -0,0 +1,37 @@
+using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+using Bit.Core.Test.AdminConsole.AutoFixture;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Xunit;
+
+namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+
+[SutProviderCustomize]
+public class ResetPasswordPolicyRequirementFactoryTests
+{
+ [Theory, BitAutoData]
+ public void AutoEnroll_WithNoPolicies_IsEmpty(SutProvider sutProvider, Guid orgId)
+ {
+ var actual = sutProvider.Sut.Create([]);
+
+ Assert.False(actual.AutoEnrollEnabled(orgId));
+ }
+
+ [Theory, BitAutoData]
+ public void AutoEnrollAdministration_WithAnyResetPasswordPolices_ReturnsEnabledOrganizationIds(
+ [PolicyDetails(PolicyType.ResetPassword)] PolicyDetails[] policies,
+ SutProvider sutProvider)
+ {
+ policies[0].SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = true });
+ policies[1].SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = false });
+ policies[2].SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = true });
+
+ var actual = sutProvider.Sut.Create(policies);
+
+ Assert.True(actual.AutoEnrollEnabled(policies[0].OrganizationId));
+ Assert.False(actual.AutoEnrollEnabled(policies[1].OrganizationId));
+ Assert.True(actual.AutoEnrollEnabled(policies[2].OrganizationId));
+ }
+}
diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs
index 4c42fdfeb9..82dc0e2ebe 100644
--- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs
+++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs
@@ -24,7 +24,6 @@ using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
-using Bit.Core.Test.AdminConsole.AutoFixture;
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
using Bit.Core.Test.AutoFixture.OrganizationUserFixtures;
using Bit.Core.Tokens;
@@ -978,306 +977,6 @@ OrganizationUserInvite invite, SutProvider sutProvider)
sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true);
}
- [Theory, BitAutoData]
- public async Task ConfirmUser_InvalidStatus(OrganizationUser confirmingUser,
- [OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser orgUser, string key,
- SutProvider sutProvider)
- {
- var organizationUserRepository = sutProvider.GetDependency();
-
- organizationUserRepository.GetByIdAsync(orgUser.Id).Returns(orgUser);
-
- var exception = await Assert.ThrowsAsync(
- () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id));
- Assert.Contains("User not valid.", exception.Message);
- }
-
- [Theory, BitAutoData]
- public async Task ConfirmUser_WrongOrganization(OrganizationUser confirmingUser,
- [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, string key,
- SutProvider sutProvider)
- {
- var organizationUserRepository = sutProvider.GetDependency();
-
- organizationUserRepository.GetByIdAsync(orgUser.Id).Returns(orgUser);
-
- var exception = await Assert.ThrowsAsync(
- () => sutProvider.Sut.ConfirmUserAsync(confirmingUser.OrganizationId, orgUser.Id, key, confirmingUser.Id));
- Assert.Contains("User not valid.", exception.Message);
- }
-
- [Theory]
- [BitAutoData(OrganizationUserType.Admin)]
- [BitAutoData(OrganizationUserType.Owner)]
- public async Task ConfirmUserToFree_AlreadyFreeAdminOrOwner_Throws(OrganizationUserType userType, Organization org, OrganizationUser confirmingUser,
- [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
- string key, SutProvider sutProvider)
- {
- var organizationUserRepository = sutProvider.GetDependency();
- var organizationRepository = sutProvider.GetDependency();
- var userRepository = sutProvider.GetDependency();
-
- org.PlanType = PlanType.Free;
- orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
- orgUser.UserId = user.Id;
- orgUser.Type = userType;
- organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
- organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(orgUser.UserId.Value).Returns(1);
- organizationRepository.GetByIdAsync(org.Id).Returns(org);
- userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
-
- var exception = await Assert.ThrowsAsync(
- () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id));
- Assert.Contains("User can only be an admin of one free organization.", exception.Message);
- }
-
- [Theory]
- [BitAutoData(PlanType.Custom, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.Custom, OrganizationUserType.Owner)]
- [BitAutoData(PlanType.EnterpriseAnnually, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.EnterpriseAnnually, OrganizationUserType.Owner)]
- [BitAutoData(PlanType.EnterpriseAnnually2020, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.EnterpriseAnnually2020, OrganizationUserType.Owner)]
- [BitAutoData(PlanType.EnterpriseAnnually2019, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.EnterpriseAnnually2019, OrganizationUserType.Owner)]
- [BitAutoData(PlanType.EnterpriseMonthly, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.EnterpriseMonthly, OrganizationUserType.Owner)]
- [BitAutoData(PlanType.EnterpriseMonthly2020, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.EnterpriseMonthly2020, OrganizationUserType.Owner)]
- [BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Owner)]
- [BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Owner)]
- [BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Owner)]
- [BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Owner)]
- [BitAutoData(PlanType.TeamsAnnually2020, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.TeamsAnnually2020, OrganizationUserType.Owner)]
- [BitAutoData(PlanType.TeamsAnnually2019, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.TeamsAnnually2019, OrganizationUserType.Owner)]
- [BitAutoData(PlanType.TeamsMonthly, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.TeamsMonthly, OrganizationUserType.Owner)]
- [BitAutoData(PlanType.TeamsMonthly2020, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.TeamsMonthly2020, OrganizationUserType.Owner)]
- [BitAutoData(PlanType.TeamsMonthly2019, OrganizationUserType.Admin)]
- [BitAutoData(PlanType.TeamsMonthly2019, OrganizationUserType.Owner)]
- public async Task ConfirmUserToNonFree_AlreadyFreeAdminOrOwner_DoesNotThrow(PlanType planType, OrganizationUserType orgUserType, Organization org, OrganizationUser confirmingUser,
- [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
- string key, SutProvider sutProvider)
- {
- var organizationUserRepository = sutProvider.GetDependency();
- var organizationRepository = sutProvider.GetDependency();
- var userRepository = sutProvider.GetDependency();
-
- org.PlanType = planType;
- orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
- orgUser.UserId = user.Id;
- orgUser.Type = orgUserType;
- orgUser.AccessSecretsManager = false;
- organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
- organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(orgUser.UserId.Value).Returns(1);
- organizationRepository.GetByIdAsync(org.Id).Returns(org);
- userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
-
- await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id);
-
- await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
- await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email);
- await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1));
- }
-
-
- [Theory, BitAutoData]
- public async Task ConfirmUser_AsUser_SingleOrgPolicy_AppliedFromConfirmingOrg_Throws(Organization org, OrganizationUser confirmingUser,
- [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
- OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy,
- string key, SutProvider sutProvider)
- {
- var organizationUserRepository = sutProvider.GetDependency();
- var organizationRepository = sutProvider.GetDependency();
- var userRepository = sutProvider.GetDependency();
- var policyService = sutProvider.GetDependency();
-
- org.PlanType = PlanType.EnterpriseAnnually;
- orgUser.Status = OrganizationUserStatusType.Accepted;
- orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
- orgUser.UserId = orgUserAnotherOrg.UserId = user.Id;
- organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
- organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg });
- organizationRepository.GetByIdAsync(org.Id).Returns(org);
- userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
- singleOrgPolicy.OrganizationId = org.Id;
- policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy });
-
- var exception = await Assert.ThrowsAsync(
- () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id));
- Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", exception.Message);
- }
-
- [Theory, BitAutoData]
- public async Task ConfirmUser_AsUser_SingleOrgPolicy_AppliedFromOtherOrg_Throws(Organization org, OrganizationUser confirmingUser,
- [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
- OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy,
- string key, SutProvider sutProvider)
- {
- var organizationUserRepository = sutProvider.GetDependency();
- var organizationRepository = sutProvider.GetDependency();
- var userRepository = sutProvider.GetDependency();
- var policyService = sutProvider.GetDependency();
-
- org.PlanType = PlanType.EnterpriseAnnually;
- orgUser.Status = OrganizationUserStatusType.Accepted;
- orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
- orgUser.UserId = orgUserAnotherOrg.UserId = user.Id;
- organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
- organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg });
- organizationRepository.GetByIdAsync(org.Id).Returns(org);
- userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
- singleOrgPolicy.OrganizationId = orgUserAnotherOrg.Id;
- policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy });
-
- var exception = await Assert.ThrowsAsync(
- () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id));
- Assert.Contains("Cannot confirm this member to the organization because they are in another organization which forbids it.", exception.Message);
- }
-
- [Theory]
- [BitAutoData(OrganizationUserType.Admin)]
- [BitAutoData(OrganizationUserType.Owner)]
- public async Task ConfirmUser_AsOwnerOrAdmin_SingleOrgPolicy_ExcludedViaUserType_Success(
- OrganizationUserType userType, Organization org, OrganizationUser confirmingUser,
- [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
- OrganizationUser orgUserAnotherOrg,
- string key, SutProvider sutProvider)
- {
- var organizationUserRepository = sutProvider.GetDependency();
- var organizationRepository = sutProvider.GetDependency();
- var userRepository = sutProvider.GetDependency();
-
- org.PlanType = PlanType.EnterpriseAnnually;
- orgUser.Type = userType;
- orgUser.Status = OrganizationUserStatusType.Accepted;
- orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
- orgUser.UserId = orgUserAnotherOrg.UserId = user.Id;
- orgUser.AccessSecretsManager = true;
- organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
- organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg });
- organizationRepository.GetByIdAsync(org.Id).Returns(org);
- userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
-
- await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id);
-
- await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
- await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, true);
- await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1));
- }
-
- [Theory, BitAutoData]
- public async Task ConfirmUser_TwoFactorPolicy_NotEnabled_Throws(Organization org, OrganizationUser confirmingUser,
- [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
- OrganizationUser orgUserAnotherOrg,
- [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy,
- string key, SutProvider sutProvider)
- {
- var organizationUserRepository = sutProvider.GetDependency();
- var organizationRepository = sutProvider.GetDependency();
- var userRepository = sutProvider.GetDependency();
- var policyService = sutProvider.GetDependency();
- var twoFactorIsEnabledQuery = sutProvider.GetDependency();
-
- org.PlanType = PlanType.EnterpriseAnnually;
- orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
- orgUser.UserId = orgUserAnotherOrg.UserId = user.Id;
- organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
- organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg });
- organizationRepository.GetByIdAsync(org.Id).Returns(org);
- userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
- twoFactorPolicy.OrganizationId = org.Id;
- policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy });
- twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id)))
- .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, false) });
-
- var exception = await Assert.ThrowsAsync(
- () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id));
- Assert.Contains("User does not have two-step login enabled.", exception.Message);
- }
-
- [Theory, BitAutoData]
- public async Task ConfirmUser_TwoFactorPolicy_Enabled_Success(Organization org, OrganizationUser confirmingUser,
- [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
- [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy,
- string key, SutProvider sutProvider)
- {
- var organizationUserRepository = sutProvider.GetDependency();
- var organizationRepository = sutProvider.GetDependency();
- var userRepository = sutProvider.GetDependency();
- var policyService = sutProvider.GetDependency();
- var twoFactorIsEnabledQuery = sutProvider.GetDependency();
-
- org.PlanType = PlanType.EnterpriseAnnually;
- orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
- orgUser.UserId = user.Id;
- organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
- organizationRepository.GetByIdAsync(org.Id).Returns(org);
- userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
- twoFactorPolicy.OrganizationId = org.Id;
- policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy });
- twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id)))
- .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, true) });
-
- await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id);
- }
-
- [Theory, BitAutoData]
- public async Task ConfirmUsers_Success(Organization org,
- OrganizationUser confirmingUser,
- [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser1,
- [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2,
- [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3,
- OrganizationUser anotherOrgUser, User user1, User user2, User user3,
- [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy,
- [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy,
- string key, SutProvider sutProvider)
- {
- var organizationUserRepository = sutProvider.GetDependency();
- var organizationRepository = sutProvider.GetDependency();
- var userRepository = sutProvider.GetDependency();
- var policyService = sutProvider.GetDependency();
- var twoFactorIsEnabledQuery = sutProvider.GetDependency();
-
- org.PlanType = PlanType.EnterpriseAnnually;
- orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = confirmingUser.OrganizationId = org.Id;
- orgUser1.UserId = user1.Id;
- orgUser2.UserId = user2.Id;
- orgUser3.UserId = user3.Id;
- anotherOrgUser.UserId = user3.Id;
- var orgUsers = new[] { orgUser1, orgUser2, orgUser3 };
- organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(orgUsers);
- organizationRepository.GetByIdAsync(org.Id).Returns(org);
- userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user1, user2, user3 });
- twoFactorPolicy.OrganizationId = org.Id;
- policyService.GetPoliciesApplicableToUserAsync(Arg.Any(), PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy });
- twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id) && ids.Contains(user3.Id)))
- .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>()
- {
- (user1.Id, true),
- (user2.Id, false),
- (user3.Id, true)
- });
- singleOrgPolicy.OrganizationId = org.Id;
- policyService.GetPoliciesApplicableToUserAsync(user3.Id, PolicyType.SingleOrg)
- .Returns(new[] { singleOrgPolicy });
- organizationUserRepository.GetManyByManyUsersAsync(default)
- .ReturnsForAnyArgs(new[] { orgUser1, orgUser2, orgUser3, anotherOrgUser });
-
- var keys = orgUsers.ToDictionary(ou => ou.Id, _ => key);
- var result = await sutProvider.Sut.ConfirmUsersAsync(confirmingUser.OrganizationId, keys, confirmingUser.Id);
- Assert.Contains("", result[0].Item2);
- Assert.Contains("User does not have two-step login enabled.", result[1].Item2);
- Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", result[2].Item2);
- }
-
[Theory, BitAutoData]
public async Task UpdateOrganizationKeysAsync_WithoutManageResetPassword_Throws(Guid orgId, string publicKey,
string privateKey, SutProvider sutProvider)
diff --git a/test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs b/test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs
new file mode 100644
index 0000000000..e677814fc1
--- /dev/null
+++ b/test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs
@@ -0,0 +1,120 @@
+using Bit.Core.Entities;
+using Bit.Core.KeyManagement.Models.Data;
+using Bit.Core.KeyManagement.UserKey.Implementations;
+using Bit.Core.Services;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Microsoft.AspNetCore.Identity;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.KeyManagement.UserKey;
+
+[SutProviderCustomize]
+public class RotateUserAccountKeysCommandTests
+{
+ [Theory, BitAutoData]
+ public async Task RejectsWrongOldMasterPassword(SutProvider sutProvider, User user,
+ RotateUserAccountKeysData model)
+ {
+ user.Email = model.MasterPasswordUnlockData.Email;
+ sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
+ .Returns(false);
+
+ var result = await sutProvider.Sut.RotateUserAccountKeysAsync(user, model);
+
+ Assert.NotEqual(IdentityResult.Success, result);
+ }
+ [Theory, BitAutoData]
+ public async Task ThrowsWhenUserIsNull(SutProvider sutProvider,
+ RotateUserAccountKeysData model)
+ {
+ await Assert.ThrowsAsync(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(null, model));
+ }
+ [Theory, BitAutoData]
+ public async Task RejectsEmailChange(SutProvider sutProvider, User user,
+ RotateUserAccountKeysData model)
+ {
+ user.Kdf = Enums.KdfType.Argon2id;
+ user.KdfIterations = 3;
+ user.KdfMemory = 64;
+ user.KdfParallelism = 4;
+
+ model.MasterPasswordUnlockData.Email = user.Email + ".different-domain";
+ model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
+ model.MasterPasswordUnlockData.KdfIterations = 3;
+ model.MasterPasswordUnlockData.KdfMemory = 64;
+ model.MasterPasswordUnlockData.KdfParallelism = 4;
+ sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
+ .Returns(true);
+ await Assert.ThrowsAsync(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(user, model));
+ }
+
+ [Theory, BitAutoData]
+ public async Task RejectsKdfChange(SutProvider sutProvider, User user,
+ RotateUserAccountKeysData model)
+ {
+ user.Kdf = Enums.KdfType.Argon2id;
+ user.KdfIterations = 3;
+ user.KdfMemory = 64;
+ user.KdfParallelism = 4;
+
+ model.MasterPasswordUnlockData.Email = user.Email;
+ model.MasterPasswordUnlockData.KdfType = Enums.KdfType.PBKDF2_SHA256;
+ model.MasterPasswordUnlockData.KdfIterations = 600000;
+ model.MasterPasswordUnlockData.KdfMemory = null;
+ model.MasterPasswordUnlockData.KdfParallelism = null;
+ sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
+ .Returns(true);
+ await Assert.ThrowsAsync(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(user, model));
+ }
+
+
+ [Theory, BitAutoData]
+ public async Task RejectsPublicKeyChange(SutProvider sutProvider, User user,
+ RotateUserAccountKeysData model)
+ {
+ user.PublicKey = "old-public";
+ user.Kdf = Enums.KdfType.Argon2id;
+ user.KdfIterations = 3;
+ user.KdfMemory = 64;
+ user.KdfParallelism = 4;
+
+ model.AccountPublicKey = "new-public";
+ model.MasterPasswordUnlockData.Email = user.Email;
+ model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
+ model.MasterPasswordUnlockData.KdfIterations = 3;
+ model.MasterPasswordUnlockData.KdfMemory = 64;
+ model.MasterPasswordUnlockData.KdfParallelism = 4;
+
+ sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
+ .Returns(true);
+
+ await Assert.ThrowsAsync(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(user, model));
+ }
+
+ [Theory, BitAutoData]
+ public async Task RotatesCorrectly(SutProvider sutProvider, User user,
+ RotateUserAccountKeysData model)
+ {
+ user.Kdf = Enums.KdfType.Argon2id;
+ user.KdfIterations = 3;
+ user.KdfMemory = 64;
+ user.KdfParallelism = 4;
+
+ model.MasterPasswordUnlockData.Email = user.Email;
+ model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
+ model.MasterPasswordUnlockData.KdfIterations = 3;
+ model.MasterPasswordUnlockData.KdfMemory = 64;
+ model.MasterPasswordUnlockData.KdfParallelism = 4;
+
+ model.AccountPublicKey = user.PublicKey;
+
+ sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
+ .Returns(true);
+
+ var result = await sutProvider.Sut.RotateUserAccountKeysAsync(user, model);
+
+ Assert.Equal(IdentityResult.Success, result);
+ }
+}
diff --git a/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs b/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs
index 53263d8805..000fa7e90c 100644
--- a/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs
+++ b/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs
@@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Identity;
using NSubstitute;
using Xunit;
-namespace Bit.Core.Test.KeyManagement.UserFeatures.UserKey;
+namespace Bit.Core.Test.KeyManagement.UserKey;
[SutProviderCustomize]
public class RotateUserKeyCommandTests
diff --git a/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs
index 066a550fa8..151bd47c44 100644
--- a/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs
+++ b/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs
@@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Entities;
+using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Test.AutoFixture.Attributes;
using Bit.Infrastructure.EFIntegration.Test.AutoFixture;
@@ -289,4 +290,27 @@ public class UserRepositoryTests
var distinctItems = returnedList.Distinct(equalityComparer);
Assert.True(!distinctItems.Skip(1).Any());
}
+
+ [CiSkippedTheory, EfUserAutoData]
+ public async Task UpdateUserKeyAndEncryptedDataAsync_Works_DataMatches(User user, SqlRepo.UserRepository sqlUserRepo)
+ {
+ var sqlUser = await sqlUserRepo.CreateAsync(user);
+ sqlUser.Kdf = KdfType.PBKDF2_SHA256;
+ sqlUser.KdfIterations = 6_000_000;
+ sqlUser.KdfMemory = 7_000_000;
+ sqlUser.KdfParallelism = 8_000_000;
+ sqlUser.MasterPassword = "masterPasswordHash";
+ sqlUser.MasterPasswordHint = "masterPasswordHint";
+ sqlUser.Email = "example@example.com";
+
+ await sqlUserRepo.UpdateUserKeyAndEncryptedDataV2Async(sqlUser, []);
+ var updatedUser = await sqlUserRepo.GetByIdAsync(sqlUser.Id);
+ Assert.Equal(sqlUser.Kdf, updatedUser.Kdf);
+ Assert.Equal(sqlUser.KdfIterations, updatedUser.KdfIterations);
+ Assert.Equal(sqlUser.KdfMemory, updatedUser.KdfMemory);
+ Assert.Equal(sqlUser.KdfParallelism, updatedUser.KdfParallelism);
+ Assert.Equal(sqlUser.MasterPassword, updatedUser.MasterPassword);
+ Assert.Equal(sqlUser.MasterPasswordHint, updatedUser.MasterPasswordHint);
+ Assert.Equal(sqlUser.Email, updatedUser.Email);
+ }
}