From 5ad515b6f4019d1ca33877400118c490f9e322bb Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Thu, 26 Mar 2026 13:03:55 -0400 Subject: [PATCH 01/20] feat(master-password): Master Password Service - Initial implementation. --- .../Interfaces/IMasterPasswordService.cs | 41 +++ .../ISetInitialMasterPasswordStateCommand.cs | 8 + .../IUpdateMasterPasswordStateCommand.cs | 8 + .../MasterPasswordService.cs | 85 +++++++ .../SetInitialMasterPasswordStateCommand.cs | 17 ++ .../UpdateMasterPasswordStateCommand.cs | 17 ++ .../UserServiceCollectionExtensions.cs | 3 + .../MasterPasswordServiceTests.cs | 236 ++++++++++++++++++ ...tInitialMasterPasswordStateCommandTests.cs | 24 ++ .../UpdateMasterPasswordStateCommandTests.cs | 24 ++ 10 files changed, 463 insertions(+) create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordStateCommand.cs create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IUpdateMasterPasswordStateCommand.cs create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommand.cs create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommand.cs create mode 100644 test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs create mode 100644 test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommandTests.cs create mode 100644 test/Core.Test/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommandTests.cs diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs new file mode 100644 index 000000000000..152618be2c1a --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs @@ -0,0 +1,41 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; + +public interface IMasterPasswordService +{ + /// + /// Mutates the user entity in-memory to set the initial master password state. + /// Does not persist to the database. + /// + /// + /// Requires that and are both null. + /// If is provided it is assigned to ; + /// otherwise the field is left unchanged. + /// + void SetInitialMasterPassword(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null); + + /// + /// Mutates the user entity and persists the result via . + /// + Task SetInitialMasterPasswordAsync(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null); + + /// + /// Mutates the user entity in-memory to update an existing master password. + /// Does not persist to the database. + /// + /// + /// Requires that the user already has a master password (). + /// Validates that matches the KDF settings already stored on the user — + /// this method is for changing the password only, not rotating KDF settings. + /// If is provided it is assigned to ; + /// otherwise the field is left unchanged. + /// + void UpdateMasterPassword(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null); + + /// + /// Mutates the user entity and persists the result via . + /// + Task UpdateMasterPasswordAsync(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null); +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordStateCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordStateCommand.cs new file mode 100644 index 000000000000..845883a19e1e --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordStateCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.Entities; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; + +public interface ISetInitialMasterPasswordStateCommand +{ + Task ExecuteAsync(User user); +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IUpdateMasterPasswordStateCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IUpdateMasterPasswordStateCommand.cs new file mode 100644 index 000000000000..8cff4c460631 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IUpdateMasterPasswordStateCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.Entities; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; + +public interface IUpdateMasterPasswordStateCommand +{ + Task ExecuteAsync(User user); +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs new file mode 100644 index 000000000000..5019b4617eec --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs @@ -0,0 +1,85 @@ +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; + +public class MasterPasswordService : IMasterPasswordService +{ + private readonly IPasswordHasher _passwordHasher; + private readonly TimeProvider _timeProvider; + private readonly ISetInitialMasterPasswordStateCommand _setInitialMasterPasswordStateCommand; + private readonly IUpdateMasterPasswordStateCommand _updateMasterPasswordStateCommand; + + public MasterPasswordService( + IPasswordHasher passwordHasher, + TimeProvider timeProvider, + ISetInitialMasterPasswordStateCommand setInitialMasterPasswordStateCommand, + IUpdateMasterPasswordStateCommand updateMasterPasswordStateCommand) + { + _passwordHasher = passwordHasher; + _timeProvider = timeProvider; + _setInitialMasterPasswordStateCommand = setInitialMasterPasswordStateCommand; + _updateMasterPasswordStateCommand = updateMasterPasswordStateCommand; + } + + public void SetInitialMasterPassword(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null) + { + if (user.MasterPassword != null || user.Key != null) + { + throw new BadRequestException("User already has a master password set."); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + user.MasterPassword = _passwordHasher.HashPassword(user, masterPasswordHash); + user.Key = key; + user.Kdf = kdf.KdfType; + user.KdfIterations = kdf.Iterations; + user.KdfMemory = kdf.Memory; + user.KdfParallelism = kdf.Parallelism; + user.LastPasswordChangeDate = now; + user.RevisionDate = user.AccountRevisionDate = now; + + if (salt != null) + { + user.MasterPasswordSalt = salt; + } + } + + public async Task SetInitialMasterPasswordAsync(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null) + { + SetInitialMasterPassword(user, masterPasswordHash, key, kdf, salt); + await _setInitialMasterPasswordStateCommand.ExecuteAsync(user); + } + + public void UpdateMasterPassword(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null) + { + if (!user.HasMasterPassword()) + { + throw new BadRequestException("User does not have an existing master password to update."); + } + + kdf.ValidateUnchangedForUser(user); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + user.MasterPassword = _passwordHasher.HashPassword(user, masterPasswordHash); + user.Key = key; + user.LastPasswordChangeDate = now; + user.RevisionDate = user.AccountRevisionDate = now; + + if (salt != null) + { + user.MasterPasswordSalt = salt; + } + } + + public async Task UpdateMasterPasswordAsync(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null) + { + UpdateMasterPassword(user, masterPasswordHash, key, kdf, salt); + await _updateMasterPasswordStateCommand.ExecuteAsync(user); + } +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommand.cs new file mode 100644 index 000000000000..0fcb7a278b8a --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommand.cs @@ -0,0 +1,17 @@ +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Repositories; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; + +public class SetInitialMasterPasswordStateCommand : ISetInitialMasterPasswordStateCommand +{ + private readonly IUserRepository _userRepository; + + public SetInitialMasterPasswordStateCommand(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public Task ExecuteAsync(User user) => _userRepository.ReplaceAsync(user); +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommand.cs new file mode 100644 index 000000000000..e508b2eb1e10 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommand.cs @@ -0,0 +1,17 @@ +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Repositories; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; + +public class UpdateMasterPasswordStateCommand : IUpdateMasterPasswordStateCommand +{ + private readonly IUserRepository _userRepository; + + public UpdateMasterPasswordStateCommand(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public Task ExecuteAsync(User user) => _userRepository.ReplaceAsync(user); +} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 693b37f47c21..06f19ea05546 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -55,6 +55,9 @@ private static void AddUserPasswordCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void AddTdeOffboardingPasswordCommands(this IServiceCollection services) diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs new file mode 100644 index 000000000000..1e0ab640d198 --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs @@ -0,0 +1,236 @@ +using Bit.Core.Auth.UserFeatures.UserMasterPassword; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.UserMasterPassword; + +public class MasterPasswordServiceTests +{ + private static SutProvider CreateSutProvider() + => new SutProvider().WithFakeTimeProvider().Create(); + + // ------------------------------------------------------------------------- + // SetInitialMasterPassword + // ------------------------------------------------------------------------- + + [Theory, BitAutoData] + public void SetInitialMasterPassword_Success(User user, string masterPasswordHash, string key, KdfSettings kdf, string salt) + { + // Arrange + var sutProvider = CreateSutProvider(); + user.MasterPassword = null; + user.Key = null; + var expectedHash = "server-hashed-" + masterPasswordHash; + sutProvider.GetDependency>() + .HashPassword(user, masterPasswordHash) + .Returns(expectedHash); + + // Act + sutProvider.Sut.SetInitialMasterPassword(user, masterPasswordHash, key, kdf, salt); + + // Assert + Assert.Equal(expectedHash, user.MasterPassword); + Assert.Equal(key, user.Key); + Assert.Equal(kdf.KdfType, user.Kdf); + Assert.Equal(kdf.Iterations, user.KdfIterations); + Assert.Equal(kdf.Memory, user.KdfMemory); + Assert.Equal(kdf.Parallelism, user.KdfParallelism); + Assert.Equal(salt, user.MasterPasswordSalt); + Assert.NotNull(user.LastPasswordChangeDate); + } + + [Theory, BitAutoData] + public void SetInitialMasterPassword_SaltNull_DoesNotSetMasterPasswordSalt(User user, string masterPasswordHash, string key, KdfSettings kdf) + { + // Arrange + var sutProvider = CreateSutProvider(); + user.MasterPassword = null; + user.Key = null; + var originalSalt = user.MasterPasswordSalt; + + // Act + sutProvider.Sut.SetInitialMasterPassword(user, masterPasswordHash, key, kdf, null); + + // Assert + Assert.Equal(originalSalt, user.MasterPasswordSalt); + } + + [Theory, BitAutoData] + public void SetInitialMasterPassword_ThrowsWhenMasterPasswordAlreadySet(User user, string masterPasswordHash, string key, KdfSettings kdf) + { + // Arrange + var sutProvider = CreateSutProvider(); + user.MasterPassword = "existing-hash"; + user.Key = null; + + // Act & Assert + var exception = Assert.Throws(() => + sutProvider.Sut.SetInitialMasterPassword(user, masterPasswordHash, key, kdf)); + Assert.Equal("User already has a master password set.", exception.Message); + } + + [Theory, BitAutoData] + public void SetInitialMasterPassword_ThrowsWhenKeyAlreadySet(User user, string masterPasswordHash, string key, KdfSettings kdf) + { + // Arrange + var sutProvider = CreateSutProvider(); + user.MasterPassword = null; + user.Key = "existing-key"; + + // Act & Assert + var exception = Assert.Throws(() => + sutProvider.Sut.SetInitialMasterPassword(user, masterPasswordHash, key, kdf)); + Assert.Equal("User already has a master password set.", exception.Message); + } + + [Theory, BitAutoData] + public async Task SetInitialMasterPasswordAsync_CallsMutationThenCommand( + User user, string masterPasswordHash, string key, KdfSettings kdf, string salt) + { + // Arrange + var sutProvider = CreateSutProvider(); + user.MasterPassword = null; + user.Key = null; + + // Act + await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf, salt); + + // Assert: mutation was applied + Assert.NotNull(user.MasterPassword); + Assert.Equal(key, user.Key); + + // Assert: command was called with the mutated user + await sutProvider.GetDependency() + .Received(1) + .ExecuteAsync(user); + } + + // ------------------------------------------------------------------------- + // UpdateMasterPassword + // ------------------------------------------------------------------------- + + [Theory, BitAutoData] + public void UpdateMasterPassword_Success(User user, string masterPasswordHash, string key, string salt) + { + // Arrange + var sutProvider = CreateSutProvider(); + var kdf = new KdfSettings + { + KdfType = user.Kdf, + Iterations = user.KdfIterations, + Memory = user.KdfMemory, + Parallelism = user.KdfParallelism + }; + user.MasterPassword = "existing-hash"; + var expectedHash = "server-hashed-" + masterPasswordHash; + sutProvider.GetDependency>() + .HashPassword(user, masterPasswordHash) + .Returns(expectedHash); + + // Act + sutProvider.Sut.UpdateMasterPassword(user, masterPasswordHash, key, kdf, salt); + + // Assert + Assert.Equal(expectedHash, user.MasterPassword); + Assert.Equal(key, user.Key); + Assert.Equal(salt, user.MasterPasswordSalt); + Assert.NotNull(user.LastPasswordChangeDate); + // KDF fields must be unchanged + Assert.Equal(kdf.KdfType, user.Kdf); + Assert.Equal(kdf.Iterations, user.KdfIterations); + Assert.Equal(kdf.Memory, user.KdfMemory); + Assert.Equal(kdf.Parallelism, user.KdfParallelism); + } + + [Theory, BitAutoData] + public void UpdateMasterPassword_SaltNull_DoesNotSetMasterPasswordSalt(User user, string masterPasswordHash, string key) + { + // Arrange + var sutProvider = CreateSutProvider(); + var kdf = new KdfSettings + { + KdfType = user.Kdf, + Iterations = user.KdfIterations, + Memory = user.KdfMemory, + Parallelism = user.KdfParallelism + }; + user.MasterPassword = "existing-hash"; + var originalSalt = user.MasterPasswordSalt; + + // Act + sutProvider.Sut.UpdateMasterPassword(user, masterPasswordHash, key, kdf, null); + + // Assert + Assert.Equal(originalSalt, user.MasterPasswordSalt); + } + + [Theory, BitAutoData] + public void UpdateMasterPassword_ThrowsWhenNoExistingPassword(User user, string masterPasswordHash, string key, KdfSettings kdf) + { + // Arrange + var sutProvider = CreateSutProvider(); + user.MasterPassword = null; + + // Act & Assert + var exception = Assert.Throws(() => + sutProvider.Sut.UpdateMasterPassword(user, masterPasswordHash, key, kdf)); + Assert.Equal("User does not have an existing master password to update.", exception.Message); + } + + [Theory, BitAutoData] + public void UpdateMasterPassword_ThrowsWhenKdfMismatch(User user, string masterPasswordHash, string key) + { + // Arrange + var sutProvider = CreateSutProvider(); + user.MasterPassword = "existing-hash"; + user.Kdf = KdfType.PBKDF2_SHA256; + user.KdfIterations = 600000; + // Pass KDF settings that differ from user's stored KDF + var mismatchedKdf = new KdfSettings + { + KdfType = KdfType.Argon2id, + Iterations = 3, + Memory = 64, + Parallelism = 4 + }; + + // Act & Assert + Assert.Throws(() => + sutProvider.Sut.UpdateMasterPassword(user, masterPasswordHash, key, mismatchedKdf)); + } + + [Theory, BitAutoData] + public async Task UpdateMasterPasswordAsync_CallsMutationThenCommand(User user, string masterPasswordHash, string key) + { + // Arrange + var sutProvider = CreateSutProvider(); + var kdf = new KdfSettings + { + KdfType = user.Kdf, + Iterations = user.KdfIterations, + Memory = user.KdfMemory, + Parallelism = user.KdfParallelism + }; + user.MasterPassword = "existing-hash"; + + // Act + await sutProvider.Sut.UpdateMasterPasswordAsync(user, masterPasswordHash, key, kdf); + + // Assert: mutation was applied + Assert.NotNull(user.MasterPassword); + Assert.Equal(key, user.Key); + + // Assert: command was called with the mutated user + await sutProvider.GetDependency() + .Received(1) + .ExecuteAsync(user); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommandTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommandTests.cs new file mode 100644 index 000000000000..cce03f1dd3d9 --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommandTests.cs @@ -0,0 +1,24 @@ +using Bit.Core.Auth.UserFeatures.UserMasterPassword; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.UserMasterPassword; + +[SutProviderCustomize] +public class SetInitialMasterPasswordStateCommandTests +{ + [Theory] + [BitAutoData] + public async Task ExecuteAsync_CallsReplaceAsync( + SutProvider sutProvider, + User user) + { + await sutProvider.Sut.ExecuteAsync(user); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(user); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommandTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommandTests.cs new file mode 100644 index 000000000000..3a7f53835907 --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommandTests.cs @@ -0,0 +1,24 @@ +using Bit.Core.Auth.UserFeatures.UserMasterPassword; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.UserMasterPassword; + +[SutProviderCustomize] +public class UpdateMasterPasswordStateCommandTests +{ + [Theory] + [BitAutoData] + public async Task ExecuteAsync_CallsReplaceAsync( + SutProvider sutProvider, + User user) + { + await sutProvider.Sut.ExecuteAsync(user); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(user); + } +} From 41bdd721c2c90ebcc595eb1c767550b7b846dd42 Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Thu, 26 Mar 2026 14:20:09 -0400 Subject: [PATCH 02/20] misc changes, don't put me up --- .../OrganizationUsersController.cs | 22 ++++- ...ganizationUserResetPasswordRequestModel.cs | 20 +++-- .../AdminRecoverAccountCommand.cs | 81 +++++++++++++++++- .../IAdminRecoverAccountCommand.cs | 15 ++++ .../Interfaces/IMasterPasswordService.cs | 13 ++- .../MasterPasswordService.cs | 53 +++++++++--- .../AdminRecoverAccountCommandTests.cs | 82 ++++++++++++++----- 7 files changed, 240 insertions(+), 46 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 897e94eb8cfe..dee0e284f4d4 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -43,6 +43,7 @@ using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using V1_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1.IRevokeOrganizationUserCommand; using V2_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; @@ -546,7 +547,26 @@ public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] Orga return TypedResults.BadRequest(new ErrorResponseModel(failureReason)); } - var result = await _adminRecoverAccountCommand.RecoverAccountAsync(orgId, targetOrganizationUser, model.NewMasterPasswordHash, model.Key); + IdentityResult result; + + if (model.UnlockAndAuthenticationDataExist()) + { + if (model.NewMasterPasswordHash == null || model.Key == null) throw new BadRequestException("Payload is malformed, not enough data to perform reset password."); + result = await _adminRecoverAccountCommand.RecoverAccountAsync( + orgId, + targetOrganizationUser, + model.NewMasterPasswordHash, + model.Key); + } + else + { + result = await _adminRecoverAccountCommand.RecoverAccountAsync( + orgId, + targetOrganizationUser, + model.MasterPasswordUnlock!.ToData(), + model.MasterPasswordAuthentication!.ToData()); + } + if (result.Succeeded) { return TypedResults.Ok(); diff --git a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs index 1278cd5b53bb..ca2513496bdd 100644 --- a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs @@ -1,15 +1,19 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Api.Request; namespace Bit.Api.Models.Request.Organizations; public class OrganizationUserResetPasswordRequestModel { - [Required] [StringLength(300)] - public string NewMasterPasswordHash { get; set; } - [Required] - public string Key { get; set; } + public string? NewMasterPasswordHash { get; set; } + public string? Key { get; set; } + + public MasterPasswordAuthenticationDataRequestModel? MasterPasswordAuthentication { get; set; } + public MasterPasswordUnlockDataRequestModel? MasterPasswordUnlock { get; set; } + + public bool UnlockAndAuthenticationDataExist() + { + return MasterPasswordAuthentication is not null && MasterPasswordUnlock is not null; + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs index 0da8a3754933..268534387a22 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs @@ -1,8 +1,10 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -17,7 +19,8 @@ public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepo IEventService eventService, IPushNotificationService pushNotificationService, IUserService userService, - TimeProvider timeProvider) : IAdminRecoverAccountCommand + TimeProvider timeProvider, + IMasterPasswordService masterPasswordService) : IAdminRecoverAccountCommand { public async Task RecoverAccountAsync(Guid orgId, OrganizationUser organizationUser, string newMasterPassword, string key) @@ -75,4 +78,80 @@ public async Task RecoverAccountAsync(Guid orgId, return IdentityResult.Success; } + + public async Task RecoverAccountAsync( + Guid orgId, + OrganizationUser organizationUser, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData) + { + // Org must be able to use reset password + var org = await organizationRepository.GetByIdAsync(orgId); + if (org == null || !org.UseResetPassword) + { + throw new BadRequestException("Organization does not allow password reset."); + } + + // Enterprise policy must be enabled + var resetPasswordPolicy = await policyQuery.RunAsync(orgId, PolicyType.ResetPassword); + if (!resetPasswordPolicy.Enabled) + { + throw new BadRequestException("Organization does not have the password reset policy enabled."); + } + + // Org User must be confirmed and have a ResetPasswordKey + if (organizationUser == null || + organizationUser.Status != OrganizationUserStatusType.Confirmed || + organizationUser.OrganizationId != orgId || + !organizationUser.IsEnrolledInAccountRecovery() || + !organizationUser.UserId.HasValue) + { + throw new BadRequestException("Organization User not valid"); + } + + var user = await userService.GetUserByIdAsync(organizationUser.UserId.Value); + if (user == null) + { + throw new NotFoundException(); + } + + if (user.UsesKeyConnector) + { + throw new BadRequestException("Cannot reset password of a user with Key Connector."); + } + + IdentityResult mutationResult; + if (user.HasMasterPassword()) + { + mutationResult = await masterPasswordService.UpdateMasterPassword( + user, + authenticationData.MasterPasswordAuthenticationHash, + unlockData.MasterKeyWrappedUserKey, + unlockData.Kdf); + } + else + { + mutationResult = await masterPasswordService.SetInitialMasterPassword( + user, + authenticationData.MasterPasswordAuthenticationHash, + unlockData.MasterKeyWrappedUserKey, + unlockData.Kdf); + } + + if (!mutationResult.Succeeded) + { + return mutationResult; + } + + // Extra modifications for this particular scenario to the user object + user.ForcePasswordReset = true; + + await userRepository.ReplaceAsync(user); + + await mailService.SendAdminResetPasswordEmailAsync(user.Email, user.Name, org.DisplayName()); + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_AdminResetPassword); + await pushNotificationService.PushLogOutAsync(user.Id); + + return IdentityResult.Success; + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs index 75babc643edf..96b092673471 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs @@ -1,5 +1,6 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; using Microsoft.AspNetCore.Identity; namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; @@ -19,6 +20,20 @@ public interface IAdminRecoverAccountCommand /// An IdentityResult indicating success or failure. /// When organization settings, policy, or user state is invalid. /// When the user does not exist. + [Obsolete("Will be replaced with the below function once we transition the endpoint to fully using the unlock and authentication data. Removal will happen in ")] Task RecoverAccountAsync(Guid orgId, OrganizationUser organizationUser, string newMasterPassword, string key); + + /// + /// Recovers an organization user's account by resetting their master password. + /// + /// The organization the user belongs to. + /// The organization user being recovered. + /// The user's new master password hash. + /// The user's new master-password-sealed user key. + /// An IdentityResult indicating success or failure. + /// When organization settings, policy, or user state is invalid. + /// When the user does not exist. + Task RecoverAccountAsync(Guid orgId, OrganizationUser organizationUser, + MasterPasswordUnlockData unlockData, MasterPasswordAuthenticationData authenticationData); } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs index 152618be2c1a..58c97d22ed60 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs @@ -1,5 +1,6 @@ using Bit.Core.Entities; using Bit.Core.KeyManagement.Models.Data; +using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; @@ -14,12 +15,14 @@ public interface IMasterPasswordService /// If is provided it is assigned to ; /// otherwise the field is left unchanged. /// - void SetInitialMasterPassword(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null); + Task SetInitialMasterPassword(User user, string masterPasswordHash, string key, KdfSettings kdf, + string? salt = null, bool validatePassword = true, bool refreshStamp = true); /// /// Mutates the user entity and persists the result via . /// - Task SetInitialMasterPasswordAsync(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null); + Task SetInitialMasterPasswordAsync(User user, string masterPasswordHash, string key, KdfSettings kdf, + string? salt = null, bool validatePassword = true, bool refreshStamp = true); /// /// Mutates the user entity in-memory to update an existing master password. @@ -32,10 +35,12 @@ public interface IMasterPasswordService /// If is provided it is assigned to ; /// otherwise the field is left unchanged. /// - void UpdateMasterPassword(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null); + Task UpdateMasterPassword(User user, string masterPasswordHash, string key, KdfSettings kdf, + string? salt = null, bool validatePassword = true, bool refreshStamp = true); /// /// Mutates the user entity and persists the result via . /// - Task UpdateMasterPasswordAsync(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null); + Task UpdateMasterPasswordAsync(User user, string masterPasswordHash, string key, KdfSettings kdf, + string? salt = null, bool validatePassword = true, bool refreshStamp = true); } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs index 5019b4617eec..3437fed81e86 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs @@ -2,39 +2,46 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Services; using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; public class MasterPasswordService : IMasterPasswordService { - private readonly IPasswordHasher _passwordHasher; + private readonly IUserService _userService; private readonly TimeProvider _timeProvider; private readonly ISetInitialMasterPasswordStateCommand _setInitialMasterPasswordStateCommand; private readonly IUpdateMasterPasswordStateCommand _updateMasterPasswordStateCommand; public MasterPasswordService( - IPasswordHasher passwordHasher, + IUserService userService, TimeProvider timeProvider, ISetInitialMasterPasswordStateCommand setInitialMasterPasswordStateCommand, IUpdateMasterPasswordStateCommand updateMasterPasswordStateCommand) { - _passwordHasher = passwordHasher; + _userService = userService; _timeProvider = timeProvider; _setInitialMasterPasswordStateCommand = setInitialMasterPasswordStateCommand; _updateMasterPasswordStateCommand = updateMasterPasswordStateCommand; } - public void SetInitialMasterPassword(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null) + public async Task SetInitialMasterPassword(User user, string masterPasswordHash, string key, + KdfSettings kdf, string? salt = null, bool validatePassword = true, bool refreshStamp = true) { if (user.MasterPassword != null || user.Key != null) { throw new BadRequestException("User already has a master password set."); } + var result = await _userService.UpdatePasswordHash(user, masterPasswordHash, validatePassword, refreshStamp); + if (!result.Succeeded) + { + return result; + } + var now = _timeProvider.GetUtcNow().UtcDateTime; - user.MasterPassword = _passwordHasher.HashPassword(user, masterPasswordHash); user.Key = key; user.Kdf = kdf.KdfType; user.KdfIterations = kdf.Iterations; @@ -47,15 +54,25 @@ public void SetInitialMasterPassword(User user, string masterPasswordHash, strin { user.MasterPasswordSalt = salt; } + + return IdentityResult.Success; } - public async Task SetInitialMasterPasswordAsync(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null) + public async Task SetInitialMasterPasswordAsync(User user, string masterPasswordHash, string key, + KdfSettings kdf, string? salt = null, bool validatePassword = true, bool refreshStamp = true) { - SetInitialMasterPassword(user, masterPasswordHash, key, kdf, salt); + var result = await SetInitialMasterPassword(user, masterPasswordHash, key, kdf, salt, validatePassword, refreshStamp); + if (!result.Succeeded) + { + return result; + } + await _setInitialMasterPasswordStateCommand.ExecuteAsync(user); + return IdentityResult.Success; } - public void UpdateMasterPassword(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null) + public async Task UpdateMasterPassword(User user, string masterPasswordHash, string key, + KdfSettings kdf, string? salt = null, bool validatePassword = true, bool refreshStamp = true) { if (!user.HasMasterPassword()) { @@ -64,9 +81,14 @@ public void UpdateMasterPassword(User user, string masterPasswordHash, string ke kdf.ValidateUnchangedForUser(user); + var result = await _userService.UpdatePasswordHash(user, masterPasswordHash, validatePassword, refreshStamp); + if (!result.Succeeded) + { + return result; + } + var now = _timeProvider.GetUtcNow().UtcDateTime; - user.MasterPassword = _passwordHasher.HashPassword(user, masterPasswordHash); user.Key = key; user.LastPasswordChangeDate = now; user.RevisionDate = user.AccountRevisionDate = now; @@ -75,11 +97,20 @@ public void UpdateMasterPassword(User user, string masterPasswordHash, string ke { user.MasterPasswordSalt = salt; } + + return IdentityResult.Success; } - public async Task UpdateMasterPasswordAsync(User user, string masterPasswordHash, string key, KdfSettings kdf, string? salt = null) + public async Task UpdateMasterPasswordAsync(User user, string masterPasswordHash, string key, + KdfSettings kdf, string? salt = null, bool validatePassword = true, bool refreshStamp = true) { - UpdateMasterPassword(user, masterPasswordHash, key, kdf, salt); + var result = await UpdateMasterPassword(user, masterPasswordHash, key, kdf, salt, validatePassword, refreshStamp); + if (!result.Succeeded) + { + return result; + } + await _updateMasterPasswordStateCommand.ExecuteAsync(user); + return IdentityResult.Success; } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs index ed6aa3191166..4a3bfbac60bd 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs @@ -4,9 +4,11 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -14,7 +16,6 @@ using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.Identity; using NSubstitute; using Xunit; @@ -25,7 +26,7 @@ public class AdminRecoverAccountCommandTests { [Theory] [BitAutoData] - public async Task RecoverAccountAsync_Success( + public async Task RecoverAccountAsync_UserHasMasterPassword_CallsUpdate( string newMasterPassword, string key, Organization organization, @@ -38,15 +39,63 @@ public async Task RecoverAccountAsync_Success( SetupValidOrganization(sutProvider, organization); SetupValidPolicy(sutProvider, organization, policy); SetupValidOrganizationUser(organizationUser, organization.Id); - SetupValidUser(sutProvider, user, organizationUser); - SetupSuccessfulPasswordUpdate(sutProvider, user, newMasterPassword); + SetupValidUser(sutProvider, user, organizationUser, hasMasterPassword: true); // Act var result = await sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key); // Assert Assert.True(result.Succeeded); - await AssertSuccessAsync(sutProvider, user, key, organization, organizationUser); + sutProvider.GetDependency().Received(1) + .UpdateMasterPassword( + Arg.Is(u => u.ForcePasswordReset), + newMasterPassword, + key, + Arg.Is(k => + k.KdfType == user.Kdf && + k.Iterations == user.KdfIterations && + k.Memory == user.KdfMemory && + k.Parallelism == user.KdfParallelism)); + sutProvider.GetDependency().DidNotReceive() + .SetInitialMasterPassword(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + await AssertCommonSuccessSideEffectsAsync(sutProvider, user, organization, organizationUser); + } + + [Theory] + [BitAutoData] + public async Task RecoverAccountAsync_UserHasNoMasterPassword_CallsSetInitial( + string newMasterPassword, + string key, + Organization organization, + OrganizationUser organizationUser, + User user, + [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, + SutProvider sutProvider) + { + // Arrange + SetupValidOrganization(sutProvider, organization); + SetupValidPolicy(sutProvider, organization, policy); + SetupValidOrganizationUser(organizationUser, organization.Id); + SetupValidUser(sutProvider, user, organizationUser, hasMasterPassword: false); + + // Act + var result = await sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key); + + // Assert + Assert.True(result.Succeeded); + sutProvider.GetDependency().Received(1) + .SetInitialMasterPassword( + Arg.Is(u => u.ForcePasswordReset), + newMasterPassword, + key, + Arg.Is(k => + k.KdfType == user.Kdf && + k.Iterations == user.KdfIterations && + k.Memory == user.KdfMemory && + k.Parallelism == user.KdfParallelism)); + sutProvider.GetDependency().DidNotReceive() + .UpdateMasterPassword(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + await AssertCommonSuccessSideEffectsAsync(sutProvider, user, organization, organizationUser); } [Theory] @@ -261,32 +310,23 @@ private static void SetupValidOrganizationUser(OrganizationUser organizationUser organizationUser.Type = OrganizationUserType.User; } - private static void SetupValidUser(SutProvider sutProvider, User user, OrganizationUser organizationUser) + private static void SetupValidUser(SutProvider sutProvider, User user, + OrganizationUser organizationUser, bool hasMasterPassword) { user.Id = organizationUser.UserId!.Value; user.UsesKeyConnector = false; + user.MasterPassword = hasMasterPassword ? "existing-hash" : null; + user.Key = hasMasterPassword ? user.Key : null; sutProvider.GetDependency() .GetUserByIdAsync(user.Id) .Returns(user); } - private static void SetupSuccessfulPasswordUpdate(SutProvider sutProvider, User user, string newMasterPassword) - { - sutProvider.GetDependency() - .UpdatePasswordHash(user, newMasterPassword) - .Returns(IdentityResult.Success); - } - - private static async Task AssertSuccessAsync(SutProvider sutProvider, User user, string key, - Organization organization, OrganizationUser organizationUser) + private static async Task AssertCommonSuccessSideEffectsAsync(SutProvider sutProvider, + User user, Organization organization, OrganizationUser organizationUser) { await sutProvider.GetDependency().Received(1).ReplaceAsync( - Arg.Is(u => - u.Id == user.Id && - u.Key == key && - u.ForcePasswordReset == true && - u.RevisionDate == u.AccountRevisionDate && - u.LastPasswordChangeDate == u.RevisionDate)); + Arg.Is(u => u.Id == user.Id && u.ForcePasswordReset)); await sutProvider.GetDependency().Received(1).SendAdminResetPasswordEmailAsync( Arg.Is(user.Email), From 39c61c73c19878bfb0f62815a0f94b34524e3e7e Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Mon, 30 Mar 2026 11:24:57 -0400 Subject: [PATCH 03/20] feat(master-password): Master Password Service - Added data payloads. Tests are broken but getting closer to the ideal setup. --- .../OrganizationUsersController.cs | 12 +-- .../AdminRecoverAccountCommand.cs | 24 ++++-- .../Data/SetInitialPasswordData.cs | 36 ++++++++ .../Data/UpdateExistingPasswordData.cs | 31 +++++++ .../Interfaces/IMasterPasswordService.cs | 46 +++------- .../MasterPasswordService.cs | 85 ++++++++++--------- src/Core/Repositories/IUserRepository.cs | 1 + .../AdminRecoverAccountCommandTests.cs | 8 +- .../MasterPasswordServiceTests.cs | 48 +++++------ 9 files changed, 170 insertions(+), 121 deletions(-) create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index dee0e284f4d4..1844789b1964 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -551,20 +551,22 @@ public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] Orga if (model.UnlockAndAuthenticationDataExist()) { - if (model.NewMasterPasswordHash == null || model.Key == null) throw new BadRequestException("Payload is malformed, not enough data to perform reset password."); + // Use new unlock and authentication data types result = await _adminRecoverAccountCommand.RecoverAccountAsync( orgId, targetOrganizationUser, - model.NewMasterPasswordHash, - model.Key); + model.MasterPasswordUnlock!.ToData(), + model.MasterPasswordAuthentication!.ToData()); } else { + // Old data types used to perform recover account + if (model.NewMasterPasswordHash == null || model.Key == null) throw new BadRequestException("Payload is malformed, not enough data to perform reset password."); result = await _adminRecoverAccountCommand.RecoverAccountAsync( orgId, targetOrganizationUser, - model.MasterPasswordUnlock!.ToData(), - model.MasterPasswordAuthentication!.ToData()); + model.NewMasterPasswordHash, + model.Key); } if (result.Succeeded) diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs index 268534387a22..01a7f2dbdcc7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; @@ -121,21 +122,28 @@ public async Task RecoverAccountAsync( } IdentityResult mutationResult; + + // We can recover an account for users who both have a master password and + // those who do not. TDE users can be recovered and will not have a password if (user.HasMasterPassword()) { - mutationResult = await masterPasswordService.UpdateMasterPassword( + mutationResult = await masterPasswordService.UpdateExistingMasterPasswordAsync( user, - authenticationData.MasterPasswordAuthenticationHash, - unlockData.MasterKeyWrappedUserKey, - unlockData.Kdf); + new UpdateExistingPasswordData + { + MasterPasswordUnlockData = unlockData, + MasterPasswordAuthenticationData = authenticationData, + }); } else { - mutationResult = await masterPasswordService.SetInitialMasterPassword( + mutationResult = await masterPasswordService.SetInitialMasterPasswordAsync( user, - authenticationData.MasterPasswordAuthenticationHash, - unlockData.MasterKeyWrappedUserKey, - unlockData.Kdf); + new SetInitialPasswordData + { + MasterPasswordUnlockData = unlockData, + MasterPasswordAuthenticationData = authenticationData, + }); } if (!mutationResult.Succeeded) diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs new file mode 100644 index 000000000000..cc1f5cbee65e --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs @@ -0,0 +1,36 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; + +public class SetInitialPasswordData +{ + public required MasterPasswordAuthenticationData MasterPasswordAuthenticationData { get; set; } + public required MasterPasswordUnlockData MasterPasswordUnlockData { get; set; } + public bool ValidatePassword { get; set; } = true; + public bool RefreshStamp { get; set; } = true; + + public void ValidateDataForUser(User user) + { + // Validate that the user does not have a master password set. + if (user.HasMasterPassword()) + { + throw new BadRequestException("User already has a master password set."); + } + + // Validate that there is no key set since there is no master password. The key + // and MasterPassword property are siblings in that they should either both be + // present or both be null, even for all TDE/KeyConnector users. + if (user.Key != null) + { + throw new BadRequestException("User already has a key set."); + } + + // Validate that there is no salt set. + if (user.MasterPasswordSalt != null) + { + throw new BadRequestException("User already has a master password set."); + } + } +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs new file mode 100644 index 000000000000..64211270565f --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs @@ -0,0 +1,31 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; + +public class UpdateExistingPasswordData +{ + public required MasterPasswordAuthenticationData MasterPasswordAuthenticationData { get; set; } + public required MasterPasswordUnlockData MasterPasswordUnlockData { get; set; } + public bool ValidatePassword { get; set; } = true; + public bool RefreshStamp { get; set; } = true; + + public void ValidateDataForUser(User user) + { + // Validate that the user has a master password already, if not then they shouldn't be updating they should + // be setting initial. + if (!user.HasMasterPassword()) + { + throw new BadRequestException("User does not have an existing master password to update."); + } + + // Validate KDF is unchanged for user + MasterPasswordAuthenticationData.Kdf.ValidateUnchangedForUser(user); + MasterPasswordUnlockData.Kdf.ValidateUnchangedForUser(user); + + // Validate Salt is unchanged for user + MasterPasswordAuthenticationData.ValidateSaltUnchangedForUser(user); + MasterPasswordUnlockData.ValidateSaltUnchangedForUser(user); + } +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs index 58c97d22ed60..14ee5c3656e3 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs @@ -1,46 +1,20 @@ -using Bit.Core.Entities; -using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Entities; using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +/// +/// This service defines the correct way to set an initial or update an existing password +/// for a user. +/// public interface IMasterPasswordService { - /// - /// Mutates the user entity in-memory to set the initial master password state. - /// Does not persist to the database. - /// - /// - /// Requires that and are both null. - /// If is provided it is assigned to ; - /// otherwise the field is left unchanged. - /// - Task SetInitialMasterPassword(User user, string masterPasswordHash, string key, KdfSettings kdf, - string? salt = null, bool validatePassword = true, bool refreshStamp = true); + Task SetInitialMasterPasswordAsync(User user, SetInitialPasswordData setInitialPasswordData); - /// - /// Mutates the user entity and persists the result via . - /// - Task SetInitialMasterPasswordAsync(User user, string masterPasswordHash, string key, KdfSettings kdf, - string? salt = null, bool validatePassword = true, bool refreshStamp = true); + Task SetInitialMasterPasswordAndSaveAsync(User user, SetInitialPasswordData setInitialPasswordData); - /// - /// Mutates the user entity in-memory to update an existing master password. - /// Does not persist to the database. - /// - /// - /// Requires that the user already has a master password (). - /// Validates that matches the KDF settings already stored on the user — - /// this method is for changing the password only, not rotating KDF settings. - /// If is provided it is assigned to ; - /// otherwise the field is left unchanged. - /// - Task UpdateMasterPassword(User user, string masterPasswordHash, string key, KdfSettings kdf, - string? salt = null, bool validatePassword = true, bool refreshStamp = true); + Task UpdateExistingMasterPasswordAsync(User user, UpdateExistingPasswordData updateExistingData); - /// - /// Mutates the user entity and persists the result via . - /// - Task UpdateMasterPasswordAsync(User user, string masterPasswordHash, string key, KdfSettings kdf, - string? salt = null, bool validatePassword = true, bool refreshStamp = true); + Task UpdateExistingMasterPasswordAndSaveAsync(User user, UpdateExistingPasswordData updateExistingData); } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs index 3437fed81e86..8672446aae85 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs @@ -1,7 +1,6 @@ -using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; -using Bit.Core.Exceptions; -using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Services; using Microsoft.AspNetCore.Identity; @@ -26,42 +25,45 @@ public MasterPasswordService( _updateMasterPasswordStateCommand = updateMasterPasswordStateCommand; } - public async Task SetInitialMasterPassword(User user, string masterPasswordHash, string key, - KdfSettings kdf, string? salt = null, bool validatePassword = true, bool refreshStamp = true) + public async Task SetInitialMasterPasswordAsync( + User user, + SetInitialPasswordData setInitialData) { - if (user.MasterPassword != null || user.Key != null) - { - throw new BadRequestException("User already has a master password set."); - } + setInitialData.ValidateDataForUser(user); - var result = await _userService.UpdatePasswordHash(user, masterPasswordHash, validatePassword, refreshStamp); + var result = await _userService.UpdatePasswordHash( + user, + setInitialData.MasterPasswordAuthenticationData.MasterPasswordAuthenticationHash, + setInitialData.ValidatePassword, + setInitialData.RefreshStamp); if (!result.Succeeded) { return result; } - var now = _timeProvider.GetUtcNow().UtcDateTime; + // Set kdf data on the user. + user.Key = setInitialData.MasterPasswordUnlockData.MasterKeyWrappedUserKey; + user.Kdf = setInitialData.MasterPasswordUnlockData.Kdf.KdfType; + user.KdfIterations = setInitialData.MasterPasswordUnlockData.Kdf.Iterations; + user.KdfMemory = setInitialData.MasterPasswordUnlockData.Kdf.Memory; + user.KdfParallelism = setInitialData.MasterPasswordUnlockData.Kdf.Parallelism; + + // Set salt on the user + user.MasterPasswordSalt = setInitialData.MasterPasswordUnlockData.Salt; - user.Key = key; - user.Kdf = kdf.KdfType; - user.KdfIterations = kdf.Iterations; - user.KdfMemory = kdf.Memory; - user.KdfParallelism = kdf.Parallelism; + var now = _timeProvider.GetUtcNow().UtcDateTime; user.LastPasswordChangeDate = now; user.RevisionDate = user.AccountRevisionDate = now; - if (salt != null) - { - user.MasterPasswordSalt = salt; - } - return IdentityResult.Success; } - public async Task SetInitialMasterPasswordAsync(User user, string masterPasswordHash, string key, - KdfSettings kdf, string? salt = null, bool validatePassword = true, bool refreshStamp = true) + // Should this use IUserRepository.SetMasterPassword? + public async Task SetInitialMasterPasswordAndSaveAsync( + User user, + SetInitialPasswordData setInitialData) { - var result = await SetInitialMasterPassword(user, masterPasswordHash, key, kdf, salt, validatePassword, refreshStamp); + var result = await SetInitialMasterPasswordAsync(user, setInitialData); if (!result.Succeeded) { return result; @@ -71,17 +73,17 @@ public async Task SetInitialMasterPasswordAsync(User user, strin return IdentityResult.Success; } - public async Task UpdateMasterPassword(User user, string masterPasswordHash, string key, - KdfSettings kdf, string? salt = null, bool validatePassword = true, bool refreshStamp = true) + public async Task UpdateExistingMasterPasswordAsync( + User user, + UpdateExistingPasswordData updateExistingData) { - if (!user.HasMasterPassword()) - { - throw new BadRequestException("User does not have an existing master password to update."); - } + updateExistingData.ValidateDataForUser(user); - kdf.ValidateUnchangedForUser(user); - - var result = await _userService.UpdatePasswordHash(user, masterPasswordHash, validatePassword, refreshStamp); + var result = await _userService.UpdatePasswordHash( + user, + updateExistingData.MasterPasswordAuthenticationData.MasterPasswordAuthenticationHash, + updateExistingData.ValidatePassword, + updateExistingData.RefreshStamp); if (!result.Succeeded) { return result; @@ -89,22 +91,21 @@ public async Task UpdateMasterPassword(User user, string masterP var now = _timeProvider.GetUtcNow().UtcDateTime; - user.Key = key; + user.Key = updateExistingData.MasterPasswordUnlockData.MasterKeyWrappedUserKey; + + user.MasterPasswordSalt = updateExistingData.MasterPasswordUnlockData.Salt; + user.LastPasswordChangeDate = now; user.RevisionDate = user.AccountRevisionDate = now; - if (salt != null) - { - user.MasterPasswordSalt = salt; - } - return IdentityResult.Success; } - public async Task UpdateMasterPasswordAsync(User user, string masterPasswordHash, string key, - KdfSettings kdf, string? salt = null, bool validatePassword = true, bool refreshStamp = true) + public async Task UpdateExistingMasterPasswordAndSaveAsync( + User user, + UpdateExistingPasswordData updateExistingData) { - var result = await UpdateMasterPassword(user, masterPasswordHash, key, kdf, salt, validatePassword, refreshStamp); + var result = await UpdateExistingMasterPasswordAsync(user, updateExistingData); if (!result.Succeeded) { return result; diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 322133ae2424..86fbc1bed36d 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -83,6 +83,7 @@ Task SetV2AccountCryptographicStateAsync( /// Server side hash of the user master authentication password hash /// Optional hint for the master password. /// A task to complete the operation. + [Obsolete("Deprecated, use MasterPasswordService to set a password.")] UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData masterPasswordUnlockData, string serverSideHashedMasterPasswordAuthenticationHash, string? masterPasswordHint); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs index 4a3bfbac60bd..e293872be481 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs @@ -47,7 +47,7 @@ public async Task RecoverAccountAsync_UserHasMasterPassword_CallsUpdate( // Assert Assert.True(result.Succeeded); sutProvider.GetDependency().Received(1) - .UpdateMasterPassword( + .UpdateExistingMasterPasswordAsync( Arg.Is(u => u.ForcePasswordReset), newMasterPassword, key, @@ -57,7 +57,7 @@ public async Task RecoverAccountAsync_UserHasMasterPassword_CallsUpdate( k.Memory == user.KdfMemory && k.Parallelism == user.KdfParallelism)); sutProvider.GetDependency().DidNotReceive() - .SetInitialMasterPassword(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + .SetInitialMasterPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); await AssertCommonSuccessSideEffectsAsync(sutProvider, user, organization, organizationUser); } @@ -84,7 +84,7 @@ public async Task RecoverAccountAsync_UserHasNoMasterPassword_CallsSetInitial( // Assert Assert.True(result.Succeeded); sutProvider.GetDependency().Received(1) - .SetInitialMasterPassword( + .SetInitialMasterPasswordAsync( Arg.Is(u => u.ForcePasswordReset), newMasterPassword, key, @@ -94,7 +94,7 @@ public async Task RecoverAccountAsync_UserHasNoMasterPassword_CallsSetInitial( k.Memory == user.KdfMemory && k.Parallelism == user.KdfParallelism)); sutProvider.GetDependency().DidNotReceive() - .UpdateMasterPassword(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + .UpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); await AssertCommonSuccessSideEffectsAsync(sutProvider, user, organization, organizationUser); } diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs index 1e0ab640d198..e7e1c06749b6 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs @@ -17,12 +17,8 @@ public class MasterPasswordServiceTests private static SutProvider CreateSutProvider() => new SutProvider().WithFakeTimeProvider().Create(); - // ------------------------------------------------------------------------- - // SetInitialMasterPassword - // ------------------------------------------------------------------------- - [Theory, BitAutoData] - public void SetInitialMasterPassword_Success(User user, string masterPasswordHash, string key, KdfSettings kdf, string salt) + public async Task SetInitialMasterPassword_Success(User user, string masterPasswordHash, string key, KdfSettings kdf, string salt) { // Arrange var sutProvider = CreateSutProvider(); @@ -34,7 +30,7 @@ public void SetInitialMasterPassword_Success(User user, string masterPasswordHas .Returns(expectedHash); // Act - sutProvider.Sut.SetInitialMasterPassword(user, masterPasswordHash, key, kdf, salt); + await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf, salt); // Assert Assert.Equal(expectedHash, user.MasterPassword); @@ -48,7 +44,7 @@ public void SetInitialMasterPassword_Success(User user, string masterPasswordHas } [Theory, BitAutoData] - public void SetInitialMasterPassword_SaltNull_DoesNotSetMasterPasswordSalt(User user, string masterPasswordHash, string key, KdfSettings kdf) + public async Task SetInitialMasterPassword_SaltNull_DoesNotSetMasterPasswordSalt(User user, string masterPasswordHash, string key, KdfSettings kdf) { // Arrange var sutProvider = CreateSutProvider(); @@ -57,14 +53,14 @@ public void SetInitialMasterPassword_SaltNull_DoesNotSetMasterPasswordSalt(User var originalSalt = user.MasterPasswordSalt; // Act - sutProvider.Sut.SetInitialMasterPassword(user, masterPasswordHash, key, kdf, null); + await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf, null); // Assert Assert.Equal(originalSalt, user.MasterPasswordSalt); } [Theory, BitAutoData] - public void SetInitialMasterPassword_ThrowsWhenMasterPasswordAlreadySet(User user, string masterPasswordHash, string key, KdfSettings kdf) + public async Task SetInitialMasterPassword_ThrowsWhenMasterPasswordAlreadySet(User user, string masterPasswordHash, string key, KdfSettings kdf) { // Arrange var sutProvider = CreateSutProvider(); @@ -72,13 +68,13 @@ public void SetInitialMasterPassword_ThrowsWhenMasterPasswordAlreadySet(User use user.Key = null; // Act & Assert - var exception = Assert.Throws(() => - sutProvider.Sut.SetInitialMasterPassword(user, masterPasswordHash, key, kdf)); + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf)); Assert.Equal("User already has a master password set.", exception.Message); } [Theory, BitAutoData] - public void SetInitialMasterPassword_ThrowsWhenKeyAlreadySet(User user, string masterPasswordHash, string key, KdfSettings kdf) + public async Task SetInitialMasterPassword_ThrowsWhenKeyAlreadySet(User user, string masterPasswordHash, string key, KdfSettings kdf) { // Arrange var sutProvider = CreateSutProvider(); @@ -86,8 +82,8 @@ public void SetInitialMasterPassword_ThrowsWhenKeyAlreadySet(User user, string m user.Key = "existing-key"; // Act & Assert - var exception = Assert.Throws(() => - sutProvider.Sut.SetInitialMasterPassword(user, masterPasswordHash, key, kdf)); + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf)); Assert.Equal("User already has a master password set.", exception.Message); } @@ -101,7 +97,7 @@ public async Task SetInitialMasterPasswordAsync_CallsMutationThenCommand( user.Key = null; // Act - await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf, salt); + await sutProvider.Sut.SetInitialMasterPasswordAndSaveAsync(user, masterPasswordHash, key, kdf, salt); // Assert: mutation was applied Assert.NotNull(user.MasterPassword); @@ -118,7 +114,7 @@ await sutProvider.GetDependency() // ------------------------------------------------------------------------- [Theory, BitAutoData] - public void UpdateMasterPassword_Success(User user, string masterPasswordHash, string key, string salt) + public async Task UpdateMasterPassword_Success(User user, string masterPasswordHash, string key, string salt) { // Arrange var sutProvider = CreateSutProvider(); @@ -136,7 +132,7 @@ public void UpdateMasterPassword_Success(User user, string masterPasswordHash, s .Returns(expectedHash); // Act - sutProvider.Sut.UpdateMasterPassword(user, masterPasswordHash, key, kdf, salt); + await sutProvider.Sut.UpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, kdf, salt); // Assert Assert.Equal(expectedHash, user.MasterPassword); @@ -151,7 +147,7 @@ public void UpdateMasterPassword_Success(User user, string masterPasswordHash, s } [Theory, BitAutoData] - public void UpdateMasterPassword_SaltNull_DoesNotSetMasterPasswordSalt(User user, string masterPasswordHash, string key) + public async Task UpdateMasterPassword_SaltNull_DoesNotSetMasterPasswordSalt(User user, string masterPasswordHash, string key) { // Arrange var sutProvider = CreateSutProvider(); @@ -166,27 +162,27 @@ public void UpdateMasterPassword_SaltNull_DoesNotSetMasterPasswordSalt(User user var originalSalt = user.MasterPasswordSalt; // Act - sutProvider.Sut.UpdateMasterPassword(user, masterPasswordHash, key, kdf, null); + await sutProvider.Sut.UpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, kdf, null); // Assert Assert.Equal(originalSalt, user.MasterPasswordSalt); } [Theory, BitAutoData] - public void UpdateMasterPassword_ThrowsWhenNoExistingPassword(User user, string masterPasswordHash, string key, KdfSettings kdf) + public async Task UpdateMasterPassword_ThrowsWhenNoExistingPassword(User user, string masterPasswordHash, string key, KdfSettings kdf) { // Arrange var sutProvider = CreateSutProvider(); user.MasterPassword = null; // Act & Assert - var exception = Assert.Throws(() => - sutProvider.Sut.UpdateMasterPassword(user, masterPasswordHash, key, kdf)); + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, kdf)); Assert.Equal("User does not have an existing master password to update.", exception.Message); } [Theory, BitAutoData] - public void UpdateMasterPassword_ThrowsWhenKdfMismatch(User user, string masterPasswordHash, string key) + public async Task UpdateMasterPassword_ThrowsWhenKdfMismatch(User user, string masterPasswordHash, string key) { // Arrange var sutProvider = CreateSutProvider(); @@ -203,8 +199,8 @@ public void UpdateMasterPassword_ThrowsWhenKdfMismatch(User user, string masterP }; // Act & Assert - Assert.Throws(() => - sutProvider.Sut.UpdateMasterPassword(user, masterPasswordHash, key, mismatchedKdf)); + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, mismatchedKdf)); } [Theory, BitAutoData] @@ -222,7 +218,7 @@ public async Task UpdateMasterPasswordAsync_CallsMutationThenCommand(User user, user.MasterPassword = "existing-hash"; // Act - await sutProvider.Sut.UpdateMasterPasswordAsync(user, masterPasswordHash, key, kdf); + await sutProvider.Sut.UpdateMasterPasswordAndSaveAsync(user, masterPasswordHash, key, kdf); // Assert: mutation was applied Assert.NotNull(user.MasterPassword); From d5245b6728fcc9db6be692b4e93231b9f5a5b7e3 Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Mon, 30 Mar 2026 14:44:39 -0400 Subject: [PATCH 04/20] feat(master-password): Master Password Service - Changed a comment or two. --- .../AccountRecovery/v2/AdminRecoverAccountCommand.cs | 1 + src/Core/Repositories/IUserRepository.cs | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs index 9e0fba600104..d29a43b38431 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs @@ -60,6 +60,7 @@ public async Task RecoverAccountAsync(RecoverAccountRequest reque // Password reset if (request.ResetMasterPassword) { + // Unwind this with PM-33141 to only use the new payload if (request.HasNewPayloads()) { var result = await HandleNewPayloadAsync(user, request); diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 86fbc1bed36d..322133ae2424 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -83,7 +83,6 @@ Task SetV2AccountCryptographicStateAsync( /// Server side hash of the user master authentication password hash /// Optional hint for the master password. /// A task to complete the operation. - [Obsolete("Deprecated, use MasterPasswordService to set a password.")] UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData masterPasswordUnlockData, string serverSideHashedMasterPasswordAuthenticationHash, string? masterPasswordHint); From 2c2ef078bface311ebdeede1912385eb5ae3f270 Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Mon, 30 Mar 2026 15:52:36 -0400 Subject: [PATCH 05/20] feat(master-password): Master Password Service - Master password service now does it's own password validation. --- .../AdminRecoverAccountCommand.cs | 2 +- .../Data/SetInitialPasswordData.cs | 3 + .../MasterPasswordService.cs | 69 +++++++++++++++++-- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs index 1fe6098faec0..a6701e324133 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs @@ -156,7 +156,7 @@ public async Task RecoverAccountAsync( await userRepository.ReplaceAsync(user); - await mailService.SendAdminResetPasswordEmailAsync(user.Email, user.Name, org.DisplayName()); + await mailService.SendAdminResetPasswordEmailAsync(user.Email, user.Name, org.DisplayName(), true, false); await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_AdminResetPassword); await pushNotificationService.PushLogOutAsync(user.Id); diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs index cc1f5cbee65e..522f4eabe02f 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs @@ -8,11 +8,14 @@ public class SetInitialPasswordData { public required MasterPasswordAuthenticationData MasterPasswordAuthenticationData { get; set; } public required MasterPasswordUnlockData MasterPasswordUnlockData { get; set; } + // Document this. public bool ValidatePassword { get; set; } = true; public bool RefreshStamp { get; set; } = true; public void ValidateDataForUser(User user) { + // TODO: Verify if + // Validate that the user does not have a master password set. if (user.HasMasterPassword()) { diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs index 8672446aae85..8267d58e6f63 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs @@ -1,26 +1,37 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; -using Bit.Core.Services; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; public class MasterPasswordService : IMasterPasswordService { - private readonly IUserService _userService; private readonly TimeProvider _timeProvider; + private readonly IPasswordHasher _passwordHasher; + private readonly IEnumerable> _passwordValidators; + private readonly UserManager _userManager; + private readonly ILogger _logger; private readonly ISetInitialMasterPasswordStateCommand _setInitialMasterPasswordStateCommand; private readonly IUpdateMasterPasswordStateCommand _updateMasterPasswordStateCommand; public MasterPasswordService( - IUserService userService, TimeProvider timeProvider, + IPasswordHasher passwordHasher, + IEnumerable> passwordValidators, + UserManager userManager, + ILogger logger, ISetInitialMasterPasswordStateCommand setInitialMasterPasswordStateCommand, - IUpdateMasterPasswordStateCommand updateMasterPasswordStateCommand) + IUpdateMasterPasswordStateCommand updateMasterPasswordStateCommand + ) { - _userService = userService; _timeProvider = timeProvider; + _passwordHasher = passwordHasher; + _passwordValidators = passwordValidators; + _userManager = userManager; + _logger = logger; _setInitialMasterPasswordStateCommand = setInitialMasterPasswordStateCommand; _updateMasterPasswordStateCommand = updateMasterPasswordStateCommand; } @@ -31,7 +42,7 @@ public async Task SetInitialMasterPasswordAsync( { setInitialData.ValidateDataForUser(user); - var result = await _userService.UpdatePasswordHash( + var result = await UpdatePasswordHash( user, setInitialData.MasterPasswordAuthenticationData.MasterPasswordAuthenticationHash, setInitialData.ValidatePassword, @@ -79,7 +90,7 @@ public async Task UpdateExistingMasterPasswordAsync( { updateExistingData.ValidateDataForUser(user); - var result = await _userService.UpdatePasswordHash( + var result = await UpdatePasswordHash( user, updateExistingData.MasterPasswordAuthenticationData.MasterPasswordAuthenticationHash, updateExistingData.ValidatePassword, @@ -114,4 +125,48 @@ public async Task UpdateExistingMasterPasswordAndSaveAsync( await _updateMasterPasswordStateCommand.ExecuteAsync(user); return IdentityResult.Success; } + + // + private async Task UpdatePasswordHash(User user, string newPassword, + bool validatePassword = true, bool refreshStamp = true) + { + if (validatePassword) + { + var validate = await ValidatePasswordInternal(user, newPassword); + if (!validate.Succeeded) + { + return validate; + } + } + + user.MasterPassword = _passwordHasher.HashPassword(user, newPassword); + if (refreshStamp) + { + user.SecurityStamp = Guid.NewGuid().ToString(); + } + + return IdentityResult.Success; + } + + private async Task ValidatePasswordInternal(User user, string password) + { + var errors = new List(); + foreach (var v in _passwordValidators) + { + var result = await v.ValidateAsync(_userManager, user, password); + if (!result.Succeeded) + { + errors.AddRange(result.Errors); + } + } + + if (errors.Count > 0) + { + _logger.LogWarning("User {userId} password validation failed: {errors}.", user.Id, + string.Join(";", errors.Select(e => e.Code))); + return IdentityResult.Failed(errors.ToArray()); + } + + return IdentityResult.Success; + } } From 53d4806736343370bc6d820296f8b7c49686325f Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Thu, 2 Apr 2026 15:47:21 -0400 Subject: [PATCH 06/20] feat(master-password): Master Password Service - Added in more fixes to use the unlock and authentication data. Also added in a new master password function to build the transaction for set password --- .../Controllers/EmergencyAccessController.cs | 11 +- .../Request/EmergencyAccessRequestModels.cs | 24 ++-- ...ganizationUserResetPasswordRequestModel.cs | 14 ++- .../AdminRecoverAccountCommand.cs | 16 +-- .../IAdminRecoverAccountCommand.cs | 2 +- .../v2/AdminRecoverAccountCommand.cs | 49 ++++----- .../EmergencyAccess/EmergencyAccessService.cs | 62 ++++++++++- .../IEmergencyAccessService.cs | 13 ++- .../Data/SetInitialPasswordData.cs | 15 ++- .../Data/UpdateExistingPasswordData.cs | 22 +++- .../Interfaces/IMasterPasswordService.cs | 35 +++++- .../MasterPasswordService.cs | 104 ++++++++++++++---- .../TdeSetPasswordCommand.cs | 28 +++-- src/Core/Services/IUserService.cs | 1 + .../Services/Implementations/UserService.cs | 1 + .../AdminRecoverAccountCommandTests.cs | 8 +- .../MasterPasswordServiceTests.cs | 18 +-- 17 files changed, 308 insertions(+), 115 deletions(-) diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index bd87e82c8a97..34ddb811b3f1 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -173,7 +173,16 @@ public async Task Takeover(Guid id) public async Task Password(Guid id, [FromBody] EmergencyAccessPasswordRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); - await _emergencyAccessService.PasswordAsync(id, user, model.NewMasterPasswordHash, model.Key); + + // Unwind this with PM-33141 to only use the new payload + if (model.HasNewPayloads()) + { + await _emergencyAccessService.FinishRecoveryTakeoverAsync(id, user, model.UnlockData!.ToData(), model.AuthenticationData!.ToData()); + } + else + { + await _emergencyAccessService.FinishRecoveryTakeoverAsync(id, user, model.NewMasterPasswordHash!, model.Key!); + } } [HttpPost("{id}/view")] diff --git a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs index 71e90f102acf..f98c2c519abe 100644 --- a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs +++ b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs @@ -1,9 +1,7 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request; @@ -13,7 +11,7 @@ public class EmergencyAccessInviteRequestModel [Required] [StrictEmailAddress] [StringLength(256)] - public string Email { get; set; } + public required string Email { get; set; } [Required] public EmergencyAccessType? Type { get; set; } [Required] @@ -28,7 +26,7 @@ public class EmergencyAccessUpdateRequestModel [Required] [Range(1, short.MaxValue)] public int WaitTimeDays { get; set; } - public string KeyEncrypted { get; set; } + public required string KeyEncrypted { get; set; } public EmergencyAccess ToEmergencyAccess(EmergencyAccess existingEmergencyAccess) { @@ -45,11 +43,17 @@ public EmergencyAccess ToEmergencyAccess(EmergencyAccess existingEmergencyAccess public class EmergencyAccessPasswordRequestModel { - [Required] [StringLength(300)] - public string NewMasterPasswordHash { get; set; } - [Required] - public string Key { get; set; } + public string? NewMasterPasswordHash { get; set; } + public string? Key { get; set; } + + public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } + public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } + + public bool HasNewPayloads() + { + return UnlockData is not null && AuthenticationData is not null; + } } public class EmergencyAccessWithIdRequestModel : EmergencyAccessUpdateRequestModel diff --git a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs index a257e62ea24a..2805809427be 100644 --- a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery.v2; using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Api.Request; namespace Bit.Api.Models.Request.Organizations; @@ -13,6 +14,9 @@ public class OrganizationUserResetPasswordRequestModel public string? NewMasterPasswordHash { get; set; } public string? Key { get; set; } + public MasterPasswordUnlockDataRequestModel? UnlockData; + public MasterPasswordAuthenticationDataRequestModel? AuthenticationData; + public RecoverAccountRequest ToCommandRequest(Guid orgId, OrganizationUser organizationUser) => new() { OrgId = orgId, @@ -20,6 +24,14 @@ public class OrganizationUserResetPasswordRequestModel ResetMasterPassword = ResetMasterPassword, ResetTwoFactor = ResetTwoFactor, NewMasterPasswordHash = NewMasterPasswordHash, - Key = Key + Key = Key, + UnlockData = UnlockData, + AuthenticationData = AuthenticationData, }; + + public void Validate() + { + // Validate that if the unlock and authentication data are present, the NewMasterPasswordHash + // and Key are not present. It should be either or. + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs index a6701e324133..142f02439384 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs @@ -13,7 +13,8 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; -public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepository, +public class AdminRecoverAccountCommand( + IOrganizationRepository organizationRepository, IPolicyQuery policyQuery, IUserRepository userRepository, IMailService mailService, @@ -23,6 +24,7 @@ public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepo TimeProvider timeProvider, IMasterPasswordService masterPasswordService) : IAdminRecoverAccountCommand { + [Obsolete("Will be replaced with the below function once we transition the endpoint to fully using the unlock and authentication data. Removal will happen in ticket: ")] public async Task RecoverAccountAsync(Guid orgId, OrganizationUser organizationUser, string newMasterPassword, string key) { @@ -127,22 +129,22 @@ public async Task RecoverAccountAsync( // those who do not. TDE users can be recovered and will not have a password if (user.HasMasterPassword()) { - mutationResult = await masterPasswordService.UpdateExistingMasterPasswordAsync( + mutationResult = await masterPasswordService.OnlyMutateUserUpdateExistingMasterPasswordAsync( user, new UpdateExistingPasswordData { - MasterPasswordUnlockData = unlockData, - MasterPasswordAuthenticationData = authenticationData, + MasterPasswordUnlock = unlockData, + MasterPasswordAuthentication = authenticationData, }); } else { - mutationResult = await masterPasswordService.SetInitialMasterPasswordAsync( + mutationResult = await masterPasswordService.OnlyMutateUserSetInitialMasterPasswordAsync( user, new SetInitialPasswordData { - MasterPasswordUnlockData = unlockData, - MasterPasswordAuthenticationData = authenticationData, + MasterPasswordUnlock = unlockData, + MasterPasswordAuthentication = authenticationData, }); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs index 96b092673471..1e763319b6be 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs @@ -20,7 +20,7 @@ public interface IAdminRecoverAccountCommand /// An IdentityResult indicating success or failure. /// When organization settings, policy, or user state is invalid. /// When the user does not exist. - [Obsolete("Will be replaced with the below function once we transition the endpoint to fully using the unlock and authentication data. Removal will happen in ")] + [Obsolete("Will be replaced with the below function once we transition the endpoint to fully using the unlock and authentication data. Removal will happen in ticket: ")] Task RecoverAccountAsync(Guid orgId, OrganizationUser organizationUser, string newMasterPassword, string key); diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs index d29a43b38431..f197f08ac76b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs @@ -15,7 +15,6 @@ using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -using Microsoft.AspNetCore.Identity; using OneOf.Types; namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery.v2; @@ -63,7 +62,7 @@ public async Task RecoverAccountAsync(RecoverAccountRequest reque // Unwind this with PM-33141 to only use the new payload if (request.HasNewPayloads()) { - var result = await HandleNewPayloadAsync(user, request); + var result = await HandlePayloadsWithUnlockAndAuthenticationDataAsync(user, request); if (result.IsError) { return result; @@ -71,7 +70,7 @@ public async Task RecoverAccountAsync(RecoverAccountRequest reque } else { - var result = await HandleOldPayloadAsync(user, request); + var result = await HandlePayloadWithDeprecatedRawDataAsync(user, request); if (result is { IsSuccess: false }) { return result; @@ -163,41 +162,39 @@ await revokeNonCompliantOrganizationUserCommand.RevokeNonCompliantOrganizationUs await Task.WhenAll(legacyRevokeOrgUserTasks); } - private async Task HandleNewPayloadAsync(User user, RecoverAccountRequest request) + private async Task HandlePayloadsWithUnlockAndAuthenticationDataAsync(User user, RecoverAccountRequest request) { - if (request.UnlockData is null || request.AuthenticationData is null) - throw new Exception("This should never happen! This is just fixing linting errors."); - - IdentityResult result; - // Check if we are setting an initial password here - if (!user.HasMasterPassword()) - { - result = await masterPasswordService.SetInitialMasterPasswordAsync(user, new SetInitialPasswordData + // We can recover an account for users who both have a master password and + // those who do not. TDE users can be account recovered which will not have + // an initial master password set. + var identityResultFromMutation = await masterPasswordService.OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( + user, + new SetInitialPasswordData { - MasterPasswordUnlockData = request.UnlockData.ToData(), - MasterPasswordAuthenticationData = request.AuthenticationData.ToData(), - }); - } - // If not then we are setting a master password - else - { - result = await masterPasswordService.UpdateExistingMasterPasswordAsync(user, new UpdateExistingPasswordData + MasterPasswordUnlock = request.UnlockData!.ToData(), + MasterPasswordAuthentication = request.AuthenticationData!.ToData(), + }, new UpdateExistingPasswordData { - MasterPasswordUnlockData = request.UnlockData.ToData(), - MasterPasswordAuthenticationData = request.AuthenticationData.ToData(), + MasterPasswordUnlock = request.UnlockData.ToData(), + MasterPasswordAuthentication = request.AuthenticationData.ToData(), }); - } - if (!result.Succeeded) + if (!identityResultFromMutation.Succeeded) { - var errorMessage = string.Join(", ", result.Errors.Select(e => e.Description)); + var errorMessage = string.Join(", ", identityResultFromMutation.Errors.Select(e => e.Description)); return new PasswordUpdateFailedError(errorMessage); } + // When we are recovering an account we want to force a password reset on the user. + user.ForcePasswordReset = true; + + await userRepository.ReplaceAsync(user); + return new None(); } - private async Task HandleOldPayloadAsync(User user, RecoverAccountRequest request) + [Obsolete("Come back and specify when this is to be removed.")] + private async Task HandlePayloadWithDeprecatedRawDataAsync(User user, RecoverAccountRequest request) { var result = await userService.UpdatePasswordHash(user, request.NewMasterPasswordHash!); if (!result.Succeeded) diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs index 6719be0b5190..836bf0cfb026 100644 --- a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs @@ -9,9 +9,12 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -32,6 +35,7 @@ public class EmergencyAccessService : IEmergencyAccessService private readonly ICipherService _cipherService; private readonly IMailService _mailService; private readonly IUserService _userService; + private readonly IMasterPasswordService _masterPasswordService; private readonly GlobalSettings _globalSettings; private readonly IDataProtectorTokenFactory _dataProtectorTokenizer; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; @@ -47,6 +51,7 @@ public EmergencyAccessService( ICipherService cipherService, IMailService mailService, IUserService userService, + IMasterPasswordService masterPasswordService, GlobalSettings globalSettings, IDataProtectorTokenFactory dataProtectorTokenizer, IRemoveOrganizationUserCommand removeOrganizationUserCommand, @@ -61,6 +66,7 @@ public EmergencyAccessService( _cipherService = cipherService; _mailService = mailService; _userService = userService; + _masterPasswordService = masterPasswordService; _globalSettings = globalSettings; _dataProtectorTokenizer = dataProtectorTokenizer; _removeOrganizationUserCommand = removeOrganizationUserCommand; @@ -366,8 +372,8 @@ public async Task> GetPoliciesAsync(Guid emergencyAccessId, return (emergencyAccess, grantor); } - // TODO PM-21687: rename this to something like FinishRecoveryTakeoverAsync - public async Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key) + [Obsolete] + public async Task FinishRecoveryTakeoverAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); @@ -399,6 +405,58 @@ public async Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string } } + public async Task FinishRecoveryTakeoverAsync( + Guid emergencyAccessId, + User granteeUser, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData) + { + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); + + if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.Takeover)) + { + throw new BadRequestException("Emergency Access not valid."); + } + + var grantor = await _userRepository.GetByIdAsync(emergencyAccess.GrantorId); + + if (grantor == null) + { + throw new BadRequestException("Grantor not found when trying to finish recovery takeover."); + } + + await _masterPasswordService.OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( + user: grantor, + new SetInitialPasswordData + { + MasterPasswordUnlock = unlockData, + MasterPasswordAuthentication = authenticationData, + }, new UpdateExistingPasswordData + { + MasterPasswordUnlock = unlockData, + MasterPasswordAuthentication = authenticationData, + }); + + // Side effects that we still need to run when performing emergency access. + + // Disable TwoFactor providers since they will otherwise block logins + grantor.SetTwoFactorProviders([]); + // Disable New Device Verification since it will otherwise block logins + grantor.VerifyDevices = false; + + await _userRepository.ReplaceAsync(grantor); + + // Remove grantor from all organizations unless Owner + var orgUser = await _organizationUserRepository.GetManyByUserAsync(grantor.Id); + foreach (var o in orgUser) + { + if (o.Type != OrganizationUserType.Owner) + { + await _removeOrganizationUserCommand.RemoveUserAsync(o.OrganizationId, grantor.Id); + } + } + } + public async Task SendNotificationsAsync() { var toNotify = await _emergencyAccessRepository.GetManyToNotifyAsync(); diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs index bfd725ac955f..ce13b4a93080 100644 --- a/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs @@ -3,6 +3,7 @@ using Bit.Core.Auth.Models.Data; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Services; using Bit.Core.Vault.Models.Data; @@ -117,7 +118,17 @@ public interface IEmergencyAccessService /// new password hash set by grantee user /// new encrypted user key /// void - Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key); + [Obsolete("Deprecated because we are switching to use unlock and authentication data types. To be removed in PM-33141")] + Task FinishRecoveryTakeoverAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key); + /// + /// Updates the grantor's password hash and updates the key for the EmergencyAccess entity using the + /// + /// Emergency Access Id being acted on + /// user making the request + /// + /// + /// + Task FinishRecoveryTakeoverAsync(Guid emergencyAccessId, User granteeUser, MasterPasswordUnlockData unlockData, MasterPasswordAuthenticationData authenticationData); /// /// sends a reminder email that there is a pending request for recovery. /// diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs index 522f4eabe02f..c5f9a31f0470 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs @@ -6,16 +6,17 @@ namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; public class SetInitialPasswordData { - public required MasterPasswordAuthenticationData MasterPasswordAuthenticationData { get; set; } - public required MasterPasswordUnlockData MasterPasswordUnlockData { get; set; } + public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } + public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } + // Document this. public bool ValidatePassword { get; set; } = true; public bool RefreshStamp { get; set; } = true; + public string? MasterPasswordHint { get; set; } = null; + public void ValidateDataForUser(User user) { - // TODO: Verify if - // Validate that the user does not have a master password set. if (user.HasMasterPassword()) { @@ -35,5 +36,11 @@ public void ValidateDataForUser(User user) { throw new BadRequestException("User already has a master password set."); } + + // Is this correct? + if (user.UsesKeyConnector) + { + throw new BadRequestException("Cannot set an initial password of a user with Key Connector."); + } } } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs index 64211270565f..7a79d80bbb1c 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs @@ -6,11 +6,15 @@ namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; public class UpdateExistingPasswordData { - public required MasterPasswordAuthenticationData MasterPasswordAuthenticationData { get; set; } - public required MasterPasswordUnlockData MasterPasswordUnlockData { get; set; } + public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } + public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } + + // Document this. public bool ValidatePassword { get; set; } = true; public bool RefreshStamp { get; set; } = true; + public string? MasterPasswordHint { get; set; } = null; + public void ValidateDataForUser(User user) { // Validate that the user has a master password already, if not then they shouldn't be updating they should @@ -20,12 +24,18 @@ public void ValidateDataForUser(User user) throw new BadRequestException("User does not have an existing master password to update."); } + // Is this correct? + if (user.UsesKeyConnector) + { + throw new BadRequestException("Cannot update password of a user with Key Connector."); + } + // Validate KDF is unchanged for user - MasterPasswordAuthenticationData.Kdf.ValidateUnchangedForUser(user); - MasterPasswordUnlockData.Kdf.ValidateUnchangedForUser(user); + MasterPasswordAuthentication.Kdf.ValidateUnchangedForUser(user); + MasterPasswordUnlock.Kdf.ValidateUnchangedForUser(user); // Validate Salt is unchanged for user - MasterPasswordAuthenticationData.ValidateSaltUnchangedForUser(user); - MasterPasswordUnlockData.ValidateSaltUnchangedForUser(user); + MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user); + MasterPasswordUnlock.ValidateSaltUnchangedForUser(user); } } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs index 14ee5c3656e3..d0dc98db5a01 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs @@ -1,20 +1,45 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; using Bit.Core.Entities; +using Bit.Core.Repositories; using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; /// -/// This service defines the correct way to set an initial or update an existing password -/// for a user. +/// This service bundles up all the ways we set an initial master password or update +/// an existing one into one place so we can perform the same /// public interface IMasterPasswordService { - Task SetInitialMasterPasswordAsync(User user, SetInitialPasswordData setInitialPasswordData); + Task OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword(User user, SetInitialPasswordData setInitialPasswordData, UpdateExistingPasswordData updateExistingData); - Task SetInitialMasterPasswordAndSaveAsync(User user, SetInitialPasswordData setInitialPasswordData); + /// + /// To be used when you only want to mutate the user object but not perform a write to the database. + /// + /// + /// + /// + Task OnlyMutateUserSetInitialMasterPasswordAsync(User user, SetInitialPasswordData setInitialPasswordData); - Task UpdateExistingMasterPasswordAsync(User user, UpdateExistingPasswordData updateExistingData); + /// + /// To be used when you only want to mutate the user object and perform a write to the database of the updated user. + /// + /// + /// + /// + Task SetInitialMasterPasswordAndSaveUserAsync(User user, SetInitialPasswordData setInitialPasswordData); + + /// + /// Key management needs to couple cryptographic operations along with the set password operation that guarantees + /// rollback if the operation is a failure. So we need a function to specifically build the operation to set the + /// password. + /// + /// + /// + /// + UpdateUserData BuildTransactionForSetInitialMasterPasswordAsync(User user, SetInitialPasswordData setInitialPasswordData); + + Task OnlyMutateUserUpdateExistingMasterPasswordAsync(User user, UpdateExistingPasswordData updateExistingData); Task UpdateExistingMasterPasswordAndSaveAsync(User user, UpdateExistingPasswordData updateExistingData); } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs index 8267d58e6f63..f0ee5ad8064d 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs @@ -1,14 +1,15 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; +using Bit.Core.Repositories; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; - namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; public class MasterPasswordService : IMasterPasswordService { + private readonly IUserRepository _userRepository; private readonly TimeProvider _timeProvider; private readonly IPasswordHasher _passwordHasher; private readonly IEnumerable> _passwordValidators; @@ -18,6 +19,7 @@ public class MasterPasswordService : IMasterPasswordService private readonly IUpdateMasterPasswordStateCommand _updateMasterPasswordStateCommand; public MasterPasswordService( + IUserRepository userRepository, TimeProvider timeProvider, IPasswordHasher passwordHasher, IEnumerable> passwordValidators, @@ -27,6 +29,7 @@ public MasterPasswordService( IUpdateMasterPasswordStateCommand updateMasterPasswordStateCommand ) { + _userRepository = userRepository; _timeProvider = timeProvider; _passwordHasher = passwordHasher; _passwordValidators = passwordValidators; @@ -36,15 +39,44 @@ IUpdateMasterPasswordStateCommand updateMasterPasswordStateCommand _updateMasterPasswordStateCommand = updateMasterPasswordStateCommand; } - public async Task SetInitialMasterPasswordAsync( + // I don't like that I have to pass in both the set and update operation here, is there + // perhaps a more elegant way to solve this? While the payloads are the same today they might not + // be someday so keeping them apart seems smart. Plus each dto has different validation + // to run. + public async Task OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( + User user, + SetInitialPasswordData setInitialData, + UpdateExistingPasswordData updateExistingPasswordData + ) + { + IdentityResult mutationResult; + // We can recover an account for users who both have a master password and + // those who do not. TDE users can be recovered and will not have a password + if (user.HasMasterPassword()) + { + mutationResult = await OnlyMutateUserUpdateExistingMasterPasswordAsync( + user, + updateExistingPasswordData); + } + else + { + mutationResult = await OnlyMutateUserSetInitialMasterPasswordAsync( + user, + setInitialData); + } + + return mutationResult; + } + + public async Task OnlyMutateUserSetInitialMasterPasswordAsync( User user, SetInitialPasswordData setInitialData) { setInitialData.ValidateDataForUser(user); - var result = await UpdatePasswordHash( + var result = await UpdateExistingPasswordHashAsync( user, - setInitialData.MasterPasswordAuthenticationData.MasterPasswordAuthenticationHash, + setInitialData.MasterPasswordAuthentication.MasterPasswordAuthenticationHash, setInitialData.ValidatePassword, setInitialData.RefreshStamp); if (!result.Succeeded) @@ -53,15 +85,22 @@ public async Task SetInitialMasterPasswordAsync( } // Set kdf data on the user. - user.Key = setInitialData.MasterPasswordUnlockData.MasterKeyWrappedUserKey; - user.Kdf = setInitialData.MasterPasswordUnlockData.Kdf.KdfType; - user.KdfIterations = setInitialData.MasterPasswordUnlockData.Kdf.Iterations; - user.KdfMemory = setInitialData.MasterPasswordUnlockData.Kdf.Memory; - user.KdfParallelism = setInitialData.MasterPasswordUnlockData.Kdf.Parallelism; + user.Key = setInitialData.MasterPasswordUnlock.MasterKeyWrappedUserKey; + user.Kdf = setInitialData.MasterPasswordUnlock.Kdf.KdfType; + user.KdfIterations = setInitialData.MasterPasswordUnlock.Kdf.Iterations; + user.KdfMemory = setInitialData.MasterPasswordUnlock.Kdf.Memory; + user.KdfParallelism = setInitialData.MasterPasswordUnlock.Kdf.Parallelism; // Set salt on the user - user.MasterPasswordSalt = setInitialData.MasterPasswordUnlockData.Salt; + user.MasterPasswordSalt = setInitialData.MasterPasswordUnlock.Salt; + + // If we've passed in a hint then set it + if (setInitialData.MasterPasswordHint != null) + { + user.MasterPasswordHint = setInitialData.MasterPasswordHint; + } + // Update time markers on the user var now = _timeProvider.GetUtcNow().UtcDateTime; user.LastPasswordChangeDate = now; user.RevisionDate = user.AccountRevisionDate = now; @@ -69,12 +108,12 @@ public async Task SetInitialMasterPasswordAsync( return IdentityResult.Success; } - // Should this use IUserRepository.SetMasterPassword? - public async Task SetInitialMasterPasswordAndSaveAsync( + public async Task SetInitialMasterPasswordAndSaveUserAsync( User user, SetInitialPasswordData setInitialData) { - var result = await SetInitialMasterPasswordAsync(user, setInitialData); + // No need to validate because we will validate in the sibling call here. + var result = await OnlyMutateUserSetInitialMasterPasswordAsync(user, setInitialData); if (!result.Succeeded) { return result; @@ -84,17 +123,36 @@ public async Task SetInitialMasterPasswordAndSaveAsync( return IdentityResult.Success; } - public async Task UpdateExistingMasterPasswordAsync( + public UpdateUserData BuildTransactionForSetInitialMasterPasswordAsync( + User user, + SetInitialPasswordData setInitialData) + { + setInitialData.ValidateDataForUser(user); + + // Hash the provided user master password authentication hash on the server side + var serverSideHashedMasterPasswordAuthenticationHash = _passwordHasher.HashPassword(user, + setInitialData.MasterPasswordAuthentication.MasterPasswordAuthenticationHash); + + var setMasterPasswordTask = _userRepository.SetMasterPassword(user.Id, + setInitialData.MasterPasswordUnlock, serverSideHashedMasterPasswordAuthenticationHash, + setInitialData.MasterPasswordHint); + + return setMasterPasswordTask; + } + + public async Task OnlyMutateUserUpdateExistingMasterPasswordAsync( User user, UpdateExistingPasswordData updateExistingData) { + // Start by validating the update payload updateExistingData.ValidateDataForUser(user); - var result = await UpdatePasswordHash( + var result = await UpdateExistingPasswordHashAsync( user, - updateExistingData.MasterPasswordAuthenticationData.MasterPasswordAuthenticationHash, + updateExistingData.MasterPasswordAuthentication.MasterPasswordAuthenticationHash, updateExistingData.ValidatePassword, updateExistingData.RefreshStamp); + if (!result.Succeeded) { return result; @@ -102,9 +160,9 @@ public async Task UpdateExistingMasterPasswordAsync( var now = _timeProvider.GetUtcNow().UtcDateTime; - user.Key = updateExistingData.MasterPasswordUnlockData.MasterKeyWrappedUserKey; + user.Key = updateExistingData.MasterPasswordUnlock.MasterKeyWrappedUserKey; - user.MasterPasswordSalt = updateExistingData.MasterPasswordUnlockData.Salt; + user.MasterPasswordSalt = updateExistingData.MasterPasswordUnlock.Salt; user.LastPasswordChangeDate = now; user.RevisionDate = user.AccountRevisionDate = now; @@ -116,7 +174,8 @@ public async Task UpdateExistingMasterPasswordAndSaveAsync( User user, UpdateExistingPasswordData updateExistingData) { - var result = await UpdateExistingMasterPasswordAsync(user, updateExistingData); + // No need to validate because we will validate in the sibling call here. + var result = await OnlyMutateUserUpdateExistingMasterPasswordAsync(user, updateExistingData); if (!result.Succeeded) { return result; @@ -126,13 +185,12 @@ public async Task UpdateExistingMasterPasswordAndSaveAsync( return IdentityResult.Success; } - // - private async Task UpdatePasswordHash(User user, string newPassword, + private async Task UpdateExistingPasswordHashAsync(User user, string newPassword, bool validatePassword = true, bool refreshStamp = true) { if (validatePassword) { - var validate = await ValidatePasswordInternal(user, newPassword); + var validate = await ValidatePasswordInternalAsync(user, newPassword); if (!validate.Succeeded) { return validate; @@ -148,7 +206,7 @@ private async Task UpdatePasswordHash(User user, string newPassw return IdentityResult.Success; } - private async Task ValidatePasswordInternal(User user, string password) + private async Task ValidatePasswordInternalAsync(User user, string password) { var errors = new List(); foreach (var v in _passwordValidators) diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs index afd28e95d93e..a8eaa1a2ea90 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs @@ -1,4 +1,5 @@ using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; @@ -12,16 +13,19 @@ namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; public class TdeSetPasswordCommand : ITdeSetPasswordCommand { private readonly IUserRepository _userRepository; + private readonly IMasterPasswordService _masterPasswordService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IPasswordHasher _passwordHasher; private readonly IEventService _eventService; public TdeSetPasswordCommand(IUserRepository userRepository, + IMasterPasswordService masterPasswordService, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, IPasswordHasher passwordHasher, IEventService eventService) { _userRepository = userRepository; + _masterPasswordService = masterPasswordService; _organizationUserRepository = organizationUserRepository; _organizationRepository = organizationRepository; _passwordHasher = passwordHasher; @@ -30,20 +34,13 @@ public TdeSetPasswordCommand(IUserRepository userRepository, public async Task SetMasterPasswordAsync(User user, SetInitialMasterPasswordDataModel masterPasswordDataModel) { - if (user.Key != null) - { - throw new BadRequestException("User already has a master password set."); - } - + // TDE scenario specific check if (user.PublicKey == null || user.PrivateKey == null) { throw new BadRequestException("TDE user account keys must be set before setting initial master password."); } - // Prevent a de-synced salt value from creating an un-decryptable unlock method - masterPasswordDataModel.MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user); - masterPasswordDataModel.MasterPasswordUnlock.ValidateSaltUnchangedForUser(user); - + // Does this need to be here? Why is this here? var org = await _organizationRepository.GetByIdentifierAsync(masterPasswordDataModel.OrgSsoIdentifier); if (org == null) { @@ -56,13 +53,14 @@ public async Task SetMasterPasswordAsync(User user, SetInitialMasterPasswordData throw new BadRequestException("User not found within organization."); } - // Hash the provided user master password authentication hash on the server side - var serverSideHashedMasterPasswordAuthenticationHash = _passwordHasher.HashPassword(user, - masterPasswordDataModel.MasterPasswordAuthentication.MasterPasswordAuthenticationHash); + var setMasterPasswordTask = _masterPasswordService.BuildTransactionForSetInitialMasterPasswordAsync(user, + new SetInitialPasswordData + { + MasterPasswordUnlock = masterPasswordDataModel.MasterPasswordUnlock, + MasterPasswordAuthentication = masterPasswordDataModel.MasterPasswordAuthentication, + MasterPasswordHint = masterPasswordDataModel.MasterPasswordHint, + }); - var setMasterPasswordTask = _userRepository.SetMasterPassword(user.Id, - masterPasswordDataModel.MasterPasswordUnlock, serverSideHashedMasterPasswordAuthenticationHash, - masterPasswordDataModel.MasterPasswordHint); await _userRepository.UpdateUserDataAsync([setMasterPasswordTask]); await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index c021fa2668e2..0409babcd580 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -72,6 +72,7 @@ Task GenerateLicenseAsync(User user, SubscriptionInfo subscriptionI Task HasPremiumFromOrganization(User user); Task GenerateSignInTokenAsync(User user, string purpose); + [Obsolete("Migrating to the MasterPasswordService for updating a password hash.", true)] Task UpdatePasswordHash(User user, string newPassword, bool validatePassword = true, bool refreshStamp = true); Task RotateApiKeyAsync(User user); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 246cae69408a..fea1057d2cff 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -1014,6 +1014,7 @@ public static bool IsLegacyUser(User user) return user.Key == null && user.MasterPassword != null && user.PrivateKey != null; } + [Obsolete("Migrating to the MasterPasswordService")] private async Task ValidatePasswordInternal(User user, string password) { var errors = new List(); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs index f8d11e3bc36b..9800855ace03 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs @@ -47,7 +47,7 @@ public async Task RecoverAccountAsync_UserHasMasterPassword_CallsUpdate( // Assert Assert.True(result.Succeeded); sutProvider.GetDependency().Received(1) - .UpdateExistingMasterPasswordAsync( + .OnlyMutateUserUpdateExistingMasterPasswordAsync( Arg.Is(u => u.ForcePasswordReset), newMasterPassword, key, @@ -57,7 +57,7 @@ public async Task RecoverAccountAsync_UserHasMasterPassword_CallsUpdate( k.Memory == user.KdfMemory && k.Parallelism == user.KdfParallelism)); sutProvider.GetDependency().DidNotReceive() - .SetInitialMasterPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + .OnlyMutateUserSetInitialMasterPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); await AssertCommonSuccessSideEffectsAsync(sutProvider, user, organization, organizationUser); } @@ -84,7 +84,7 @@ public async Task RecoverAccountAsync_UserHasNoMasterPassword_CallsSetInitial( // Assert Assert.True(result.Succeeded); sutProvider.GetDependency().Received(1) - .SetInitialMasterPasswordAsync( + .OnlyMutateUserSetInitialMasterPasswordAsync( Arg.Is(u => u.ForcePasswordReset), newMasterPassword, key, @@ -94,7 +94,7 @@ public async Task RecoverAccountAsync_UserHasNoMasterPassword_CallsSetInitial( k.Memory == user.KdfMemory && k.Parallelism == user.KdfParallelism)); sutProvider.GetDependency().DidNotReceive() - .UpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + .OnlyMutateUserUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); await AssertCommonSuccessSideEffectsAsync(sutProvider, user, organization, organizationUser); } diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs index e7e1c06749b6..bb68afdfb374 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs @@ -30,7 +30,7 @@ public async Task SetInitialMasterPassword_Success(User user, string masterPassw .Returns(expectedHash); // Act - await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf, salt); + await sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf, salt); // Assert Assert.Equal(expectedHash, user.MasterPassword); @@ -53,7 +53,7 @@ public async Task SetInitialMasterPassword_SaltNull_DoesNotSetMasterPasswordSalt var originalSalt = user.MasterPasswordSalt; // Act - await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf, null); + await sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf, null); // Assert Assert.Equal(originalSalt, user.MasterPasswordSalt); @@ -69,7 +69,7 @@ public async Task SetInitialMasterPassword_ThrowsWhenMasterPasswordAlreadySet(Us // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf)); + sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf)); Assert.Equal("User already has a master password set.", exception.Message); } @@ -83,7 +83,7 @@ public async Task SetInitialMasterPassword_ThrowsWhenKeyAlreadySet(User user, st // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf)); + sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf)); Assert.Equal("User already has a master password set.", exception.Message); } @@ -97,7 +97,7 @@ public async Task SetInitialMasterPasswordAsync_CallsMutationThenCommand( user.Key = null; // Act - await sutProvider.Sut.SetInitialMasterPasswordAndSaveAsync(user, masterPasswordHash, key, kdf, salt); + await sutProvider.Sut.SetInitialMasterPasswordAndSaveUserAsync(user, masterPasswordHash, key, kdf, salt); // Assert: mutation was applied Assert.NotNull(user.MasterPassword); @@ -132,7 +132,7 @@ public async Task UpdateMasterPassword_Success(User user, string masterPasswordH .Returns(expectedHash); // Act - await sutProvider.Sut.UpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, kdf, salt); + await sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, kdf, salt); // Assert Assert.Equal(expectedHash, user.MasterPassword); @@ -162,7 +162,7 @@ public async Task UpdateMasterPassword_SaltNull_DoesNotSetMasterPasswordSalt(Use var originalSalt = user.MasterPasswordSalt; // Act - await sutProvider.Sut.UpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, kdf, null); + await sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, kdf, null); // Assert Assert.Equal(originalSalt, user.MasterPasswordSalt); @@ -177,7 +177,7 @@ public async Task UpdateMasterPassword_ThrowsWhenNoExistingPassword(User user, s // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, kdf)); + sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, kdf)); Assert.Equal("User does not have an existing master password to update.", exception.Message); } @@ -200,7 +200,7 @@ public async Task UpdateMasterPassword_ThrowsWhenKdfMismatch(User user, string m // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, mismatchedKdf)); + sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, mismatchedKdf)); } [Theory, BitAutoData] From f31c6c430f543561902ec1b3fc1dd5e61b1c5ce4 Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Tue, 7 Apr 2026 12:09:32 -0400 Subject: [PATCH 07/20] feat(master-password): Master Password Service - Added in more changes around set data and updating occurences to use the master password service. --- .../Request/EmergencyAccessRequestModels.cs | 15 +- ...ganizationUserResetPasswordRequestModel.cs | 15 +- .../AdminRecoverAccountCommand.cs | 2 +- .../IAdminRecoverAccountCommand.cs | 2 +- .../v2/AdminRecoverAccountCommand.cs | 8 +- .../Data/SetInitialMasterPasswordDataModel.cs | 10 +- .../EmergencyAccess/EmergencyAccessService.cs | 6 +- .../SetInitialOrChangeExistingPasswordData.cs | 43 ++++++ .../Data/SetInitialPasswordData.cs | 14 +- .../Data/UpdateExistingPasswordData.cs | 12 +- .../Interfaces/IMasterPasswordService.cs | 130 ++++++++++++++++-- .../MasterPasswordService.cs | 73 ++++------ .../SetInitialMasterPasswordCommand.cs | 27 ++-- 13 files changed, 257 insertions(+), 100 deletions(-) create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialOrChangeExistingPasswordData.cs diff --git a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs index f98c2c519abe..e96c68db9590 100644 --- a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs +++ b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs @@ -41,7 +41,7 @@ public EmergencyAccess ToEmergencyAccess(EmergencyAccess existingEmergencyAccess } } -public class EmergencyAccessPasswordRequestModel +public class EmergencyAccessPasswordRequestModel : IValidatableObject { [StringLength(300)] public string? NewMasterPasswordHash { get; set; } @@ -54,6 +54,19 @@ public bool HasNewPayloads() { return UnlockData is not null && AuthenticationData is not null; } + + public IEnumerable Validate(ValidationContext validationContext) + { + var hasNewPayloads = UnlockData is not null && AuthenticationData is not null; + var hasLegacyPayloads = NewMasterPasswordHash is not null || Key is not null; + + if (hasNewPayloads && hasLegacyPayloads) + { + yield return new ValidationResult( + "Cannot provide both new payloads (UnlockData/AuthenticationData) and legacy payloads (NewMasterPasswordHash/Key).", + [nameof(UnlockData), nameof(AuthenticationData), nameof(NewMasterPasswordHash), nameof(Key)]); + } + } } public class EmergencyAccessWithIdRequestModel : EmergencyAccessUpdateRequestModel diff --git a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs index 2805809427be..4a8e0e80f3ca 100644 --- a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs @@ -5,7 +5,7 @@ namespace Bit.Api.Models.Request.Organizations; -public class OrganizationUserResetPasswordRequestModel +public class OrganizationUserResetPasswordRequestModel : IValidatableObject { public bool ResetMasterPassword { get; set; } public bool ResetTwoFactor { get; set; } @@ -29,9 +29,16 @@ public class OrganizationUserResetPasswordRequestModel AuthenticationData = AuthenticationData, }; - public void Validate() + public IEnumerable Validate(ValidationContext validationContext) { - // Validate that if the unlock and authentication data are present, the NewMasterPasswordHash - // and Key are not present. It should be either or. + var hasNewPayloads = UnlockData is not null && AuthenticationData is not null; + var hasLegacyPayloads = NewMasterPasswordHash is not null || Key is not null; + + if (hasNewPayloads && hasLegacyPayloads) + { + yield return new ValidationResult( + "Cannot provide both new payloads (UnlockData/AuthenticationData) and legacy payloads (NewMasterPasswordHash/Key).", + [nameof(UnlockData), nameof(AuthenticationData), nameof(NewMasterPasswordHash), nameof(Key)]); + } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs index 142f02439384..1488f2830d49 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs @@ -24,7 +24,7 @@ public class AdminRecoverAccountCommand( TimeProvider timeProvider, IMasterPasswordService masterPasswordService) : IAdminRecoverAccountCommand { - [Obsolete("Will be replaced with the below function once we transition the endpoint to fully using the unlock and authentication data. Removal will happen in ticket: ")] + [Obsolete("Will be replaced with the below function once we transition the endpoint to fully using the unlock and authentication data. To be removed in PM-33141")] public async Task RecoverAccountAsync(Guid orgId, OrganizationUser organizationUser, string newMasterPassword, string key) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs index 1e763319b6be..7fcd933b0807 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs @@ -20,7 +20,7 @@ public interface IAdminRecoverAccountCommand /// An IdentityResult indicating success or failure. /// When organization settings, policy, or user state is invalid. /// When the user does not exist. - [Obsolete("Will be replaced with the below function once we transition the endpoint to fully using the unlock and authentication data. Removal will happen in ticket: ")] + [Obsolete("Will be replaced with the below function once we transition the endpoint to fully using the unlock and authentication data. To be removed in PM-33141")] Task RecoverAccountAsync(Guid orgId, OrganizationUser organizationUser, string newMasterPassword, string key); diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs index f197f08ac76b..323498c737e6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs @@ -169,14 +169,10 @@ private async Task HandlePayloadsWithUnlockAndAuthenticationDataA // an initial master password set. var identityResultFromMutation = await masterPasswordService.OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( user, - new SetInitialPasswordData + new SetInitialOrChangeExistingPasswordData { MasterPasswordUnlock = request.UnlockData!.ToData(), MasterPasswordAuthentication = request.AuthenticationData!.ToData(), - }, new UpdateExistingPasswordData - { - MasterPasswordUnlock = request.UnlockData.ToData(), - MasterPasswordAuthentication = request.AuthenticationData.ToData(), }); if (!identityResultFromMutation.Succeeded) @@ -193,7 +189,7 @@ private async Task HandlePayloadsWithUnlockAndAuthenticationDataA return new None(); } - [Obsolete("Come back and specify when this is to be removed.")] + [Obsolete("To be removed in PM-33141")] private async Task HandlePayloadWithDeprecatedRawDataAsync(User user, RecoverAccountRequest request) { var result = await userService.UpdatePasswordHash(user, request.NewMasterPasswordHash!); diff --git a/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs b/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs index 82bcb3da5ebd..fcfc7449c8de 100644 --- a/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs +++ b/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs @@ -1,4 +1,5 @@ -using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.KeyManagement.Models.Data; namespace Bit.Core.Auth.Models.Data; @@ -20,4 +21,11 @@ public class SetInitialMasterPasswordDataModel /// public required UserAccountKeysData? AccountKeys { get; set; } public string? MasterPasswordHint { get; set; } + + public SetInitialPasswordData ToSetInitialPasswordData() => new SetInitialPasswordData + { + MasterPasswordAuthentication = MasterPasswordAuthentication, + MasterPasswordUnlock = MasterPasswordUnlock, + MasterPasswordHint = MasterPasswordHint + }; } diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs index 836bf0cfb026..0dc2a19eef43 100644 --- a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs @@ -427,11 +427,7 @@ public async Task FinishRecoveryTakeoverAsync( await _masterPasswordService.OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( user: grantor, - new SetInitialPasswordData - { - MasterPasswordUnlock = unlockData, - MasterPasswordAuthentication = authenticationData, - }, new UpdateExistingPasswordData + new SetInitialOrChangeExistingPasswordData { MasterPasswordUnlock = unlockData, MasterPasswordAuthentication = authenticationData, diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialOrChangeExistingPasswordData.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialOrChangeExistingPasswordData.cs new file mode 100644 index 000000000000..1bda5cc43e7e --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialOrChangeExistingPasswordData.cs @@ -0,0 +1,43 @@ +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; + +public class SetInitialOrChangeExistingPasswordData +{ + public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } + public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } + + /// + /// When true, runs the new password hash through the registered + /// pipeline before hashing. + /// Set to false only in flows where password policy validation has already been enforced + /// (e.g. admin-initiated recovery). Defaults to true. + /// + public bool ValidatePassword { get; set; } = true; + /// + /// When true, rotates , which invalidates + /// all active sessions and authentication tokens for the user. Set to false only when + /// intentionally preserving existing sessions. Defaults to true. + /// + public bool RefreshStamp { get; set; } = true; + + public string? MasterPasswordHint { get; set; } = null; + + public SetInitialPasswordData ToSetInitialData() => new() + { + MasterPasswordAuthentication = MasterPasswordAuthentication, + MasterPasswordUnlock = MasterPasswordUnlock, + ValidatePassword = ValidatePassword, + RefreshStamp = RefreshStamp, + MasterPasswordHint = MasterPasswordHint + }; + + public UpdateExistingPasswordData ToUpdateExistingData() => new() + { + MasterPasswordAuthentication = MasterPasswordAuthentication, + MasterPasswordUnlock = MasterPasswordUnlock, + ValidatePassword = ValidatePassword, + RefreshStamp = RefreshStamp, + MasterPasswordHint = MasterPasswordHint + }; +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs index c5f9a31f0470..e81024c08f4b 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs @@ -9,8 +9,18 @@ public class SetInitialPasswordData public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } - // Document this. + /// + /// When true, runs the new password hash through the registered + /// pipeline before hashing. + /// Set to false only in flows where password policy validation has already been enforced + /// (e.g. admin-initiated recovery). Defaults to true. + /// public bool ValidatePassword { get; set; } = true; + /// + /// When true, rotates , which invalidates + /// all active sessions and authentication tokens for the user. Set to false only when + /// intentionally preserving existing sessions. Defaults to true. + /// public bool RefreshStamp { get; set; } = true; public string? MasterPasswordHint { get; set; } = null; @@ -37,7 +47,7 @@ public void ValidateDataForUser(User user) throw new BadRequestException("User already has a master password set."); } - // Is this correct? + // Once a user is in the KeyConnector state they cannot become a master password user again so we can if (user.UsesKeyConnector) { throw new BadRequestException("Cannot set an initial password of a user with Key Connector."); diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs index 7a79d80bbb1c..05fc9b94078c 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs @@ -9,8 +9,18 @@ public class UpdateExistingPasswordData public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } - // Document this. + /// + /// When true, runs the new password hash through the registered + /// pipeline before hashing. + /// Set to false only in flows where password policy validation has already been enforced + /// (e.g. admin-initiated recovery). Defaults to true. + /// public bool ValidatePassword { get; set; } = true; + /// + /// When true, rotates , which invalidates + /// all active sessions and authentication tokens for the user. Set to false only when + /// intentionally preserving existing sessions. Defaults to true. + /// public bool RefreshStamp { get; set; } = true; public string? MasterPasswordHint { get; set; } = null; diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs index d0dc98db5a01..bd668516049b 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs @@ -11,35 +11,135 @@ namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; /// public interface IMasterPasswordService { - Task OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword(User user, SetInitialPasswordData setInitialPasswordData, UpdateExistingPasswordData updateExistingData); + /// + /// Inspects the user's current state and dispatches to either + /// or + /// accordingly. + /// Mutates the object in memory only — no database write is performed. + /// + /// + /// The user object to mutate. Whether the user already has a master password determines + /// which code path executes. + /// + /// + /// Combined cryptographic and authentication data that covers both the set-initial and + /// update-existing paths. Converted internally via + /// or + /// . + /// + /// + /// if the mutation succeeded; a failure result + /// containing validation errors if ValidatePassword is set and the password + /// fails the registered pipeline. + /// + Task OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword(User user, SetInitialOrChangeExistingPasswordData setOrUpdatePasswordData); /// - /// To be used when you only want to mutate the user object but not perform a write to the database. + /// Applies a new initial master password to the object in memory only — + /// no database write is performed. Use when the caller controls persistence (e.g. key management + /// flows that must compose this mutation with other transactional operations). /// - /// - /// - /// + /// + /// The user object to mutate. Must not already have a master password; must have no existing + /// Key or MasterPasswordSalt; must not be a Key Connector user. + /// Validated via . + /// + /// + /// Cryptographic and authentication data required to set the initial password, including + /// MasterPasswordAuthentication (hashed credential used for login), + /// MasterPasswordUnlock (KDF parameters and wrapped user key), + /// and control flags ValidatePassword and RefreshStamp. + /// + /// + /// if the mutation succeeded; a failure result + /// containing validation errors if ValidatePassword is set and the password + /// fails the registered pipeline. + /// Task OnlyMutateUserSetInitialMasterPasswordAsync(User user, SetInitialPasswordData setInitialPasswordData); /// - /// To be used when you only want to mutate the user object and perform a write to the database of the updated user. + /// Applies a new initial master password to the object and persists + /// the updated user to the database. Use when no external transaction coordination is needed. /// - /// - /// - /// + /// + /// The user object to mutate and persist. Subject to the same preconditions as + /// . + /// + /// + /// Cryptographic and authentication data required to set the initial password. See + /// for field details. + /// + /// + /// if the mutation and save succeeded; a failure result + /// containing validation errors if ValidatePassword is set and the password + /// fails the registered pipeline. + /// Task SetInitialMasterPasswordAndSaveUserAsync(User user, SetInitialPasswordData setInitialPasswordData); /// - /// Key management needs to couple cryptographic operations along with the set password operation that guarantees - /// rollback if the operation is a failure. So we need a function to specifically build the operation to set the - /// password. + /// Returns a deferred database write (as an delegate) for setting + /// the initial master password. The delegate is intended to be passed to + /// , which executes all supplied delegates + /// within a single SQL transaction. Composing this delegate with others (e.g. cryptographic key + /// writes) ensures every write succeeds or the entire batch rolls back atomically — a guarantee + /// cannot provide on its own. + /// + /// Note: despite the Async suffix, this method is synchronous — it constructs and returns + /// the delegate without performing any I/O. + /// /// - /// - /// - /// + /// + /// The user whose initial master password state will be written when the returned delegate is invoked. + /// + /// + /// Cryptographic and authentication data required to set the initial password. See + /// for field details. + /// + /// + /// An delegate suitable for inclusion in a batch passed to + /// . + /// UpdateUserData BuildTransactionForSetInitialMasterPasswordAsync(User user, SetInitialPasswordData setInitialPasswordData); + /// + /// Applies a new master password over the user's existing one, mutating the + /// object in memory only — no database write is performed. + /// Use when the caller controls persistence. + /// + /// + /// The user object to mutate. Must already have a master password; must not be a Key Connector + /// user. KDF parameters and salt must be unchanged relative to the values in + /// . Validated via + /// . + /// + /// + /// Cryptographic and authentication data for the updated password, including + /// MasterPasswordAuthentication, MasterPasswordUnlock, + /// and control flags ValidatePassword and RefreshStamp. + /// + /// + /// if the mutation succeeded; a failure result + /// containing validation errors if ValidatePassword is set and the password + /// fails the registered pipeline. + /// Task OnlyMutateUserUpdateExistingMasterPasswordAsync(User user, UpdateExistingPasswordData updateExistingData); + /// + /// Applies a new master password over the user's existing one and persists the updated user + /// to the database. Use when no external transaction coordination is needed. + /// + /// + /// The user object to mutate and persist. Subject to the same preconditions as + /// . + /// + /// + /// Cryptographic and authentication data for the updated password. See + /// for field details. + /// + /// + /// if the mutation and save succeeded; a failure result + /// containing validation errors if ValidatePassword is set and the password + /// fails the registered pipeline. + /// Task UpdateExistingMasterPasswordAndSaveAsync(User user, UpdateExistingPasswordData updateExistingData); } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs index f0ee5ad8064d..f89e360de908 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs @@ -7,47 +7,20 @@ namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; -public class MasterPasswordService : IMasterPasswordService +public class MasterPasswordService( + IUserRepository userRepository, + TimeProvider timeProvider, + IPasswordHasher passwordHasher, + IEnumerable> passwordValidators, + UserManager userManager, + ILogger logger, + ISetInitialMasterPasswordStateCommand setInitialMasterPasswordStateCommand, + IUpdateMasterPasswordStateCommand updateMasterPasswordStateCommand) + : IMasterPasswordService { - private readonly IUserRepository _userRepository; - private readonly TimeProvider _timeProvider; - private readonly IPasswordHasher _passwordHasher; - private readonly IEnumerable> _passwordValidators; - private readonly UserManager _userManager; - private readonly ILogger _logger; - private readonly ISetInitialMasterPasswordStateCommand _setInitialMasterPasswordStateCommand; - private readonly IUpdateMasterPasswordStateCommand _updateMasterPasswordStateCommand; - - public MasterPasswordService( - IUserRepository userRepository, - TimeProvider timeProvider, - IPasswordHasher passwordHasher, - IEnumerable> passwordValidators, - UserManager userManager, - ILogger logger, - ISetInitialMasterPasswordStateCommand setInitialMasterPasswordStateCommand, - IUpdateMasterPasswordStateCommand updateMasterPasswordStateCommand - ) - { - _userRepository = userRepository; - _timeProvider = timeProvider; - _passwordHasher = passwordHasher; - _passwordValidators = passwordValidators; - _userManager = userManager; - _logger = logger; - _setInitialMasterPasswordStateCommand = setInitialMasterPasswordStateCommand; - _updateMasterPasswordStateCommand = updateMasterPasswordStateCommand; - } - - // I don't like that I have to pass in both the set and update operation here, is there - // perhaps a more elegant way to solve this? While the payloads are the same today they might not - // be someday so keeping them apart seems smart. Plus each dto has different validation - // to run. public async Task OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( User user, - SetInitialPasswordData setInitialData, - UpdateExistingPasswordData updateExistingPasswordData - ) + SetInitialOrChangeExistingPasswordData setOrUpdatePasswordData) { IdentityResult mutationResult; // We can recover an account for users who both have a master password and @@ -56,13 +29,13 @@ UpdateExistingPasswordData updateExistingPasswordData { mutationResult = await OnlyMutateUserUpdateExistingMasterPasswordAsync( user, - updateExistingPasswordData); + setOrUpdatePasswordData.ToUpdateExistingData()); } else { mutationResult = await OnlyMutateUserSetInitialMasterPasswordAsync( user, - setInitialData); + setOrUpdatePasswordData.ToSetInitialData()); } return mutationResult; @@ -101,7 +74,7 @@ public async Task OnlyMutateUserSetInitialMasterPasswordAsync( } // Update time markers on the user - var now = _timeProvider.GetUtcNow().UtcDateTime; + var now = timeProvider.GetUtcNow().UtcDateTime; user.LastPasswordChangeDate = now; user.RevisionDate = user.AccountRevisionDate = now; @@ -119,7 +92,7 @@ public async Task SetInitialMasterPasswordAndSaveUserAsync( return result; } - await _setInitialMasterPasswordStateCommand.ExecuteAsync(user); + await setInitialMasterPasswordStateCommand.ExecuteAsync(user); return IdentityResult.Success; } @@ -130,10 +103,10 @@ public UpdateUserData BuildTransactionForSetInitialMasterPasswordAsync( setInitialData.ValidateDataForUser(user); // Hash the provided user master password authentication hash on the server side - var serverSideHashedMasterPasswordAuthenticationHash = _passwordHasher.HashPassword(user, + var serverSideHashedMasterPasswordAuthenticationHash = passwordHasher.HashPassword(user, setInitialData.MasterPasswordAuthentication.MasterPasswordAuthenticationHash); - var setMasterPasswordTask = _userRepository.SetMasterPassword(user.Id, + var setMasterPasswordTask = userRepository.SetMasterPassword(user.Id, setInitialData.MasterPasswordUnlock, serverSideHashedMasterPasswordAuthenticationHash, setInitialData.MasterPasswordHint); @@ -158,7 +131,7 @@ public async Task OnlyMutateUserUpdateExistingMasterPasswordAsyn return result; } - var now = _timeProvider.GetUtcNow().UtcDateTime; + var now = timeProvider.GetUtcNow().UtcDateTime; user.Key = updateExistingData.MasterPasswordUnlock.MasterKeyWrappedUserKey; @@ -181,7 +154,7 @@ public async Task UpdateExistingMasterPasswordAndSaveAsync( return result; } - await _updateMasterPasswordStateCommand.ExecuteAsync(user); + await updateMasterPasswordStateCommand.ExecuteAsync(user); return IdentityResult.Success; } @@ -197,7 +170,7 @@ private async Task UpdateExistingPasswordHashAsync(User user, st } } - user.MasterPassword = _passwordHasher.HashPassword(user, newPassword); + user.MasterPassword = passwordHasher.HashPassword(user, newPassword); if (refreshStamp) { user.SecurityStamp = Guid.NewGuid().ToString(); @@ -209,9 +182,9 @@ private async Task UpdateExistingPasswordHashAsync(User user, st private async Task ValidatePasswordInternalAsync(User user, string password) { var errors = new List(); - foreach (var v in _passwordValidators) + foreach (var v in passwordValidators) { - var result = await v.ValidateAsync(_userManager, user, password); + var result = await v.ValidateAsync(userManager, user, password); if (!result.Succeeded) { errors.AddRange(result.Errors); @@ -220,7 +193,7 @@ private async Task ValidatePasswordInternalAsync(User user, stri if (errors.Count > 0) { - _logger.LogWarning("User {userId} password validation failed: {errors}.", user.Id, + logger.LogWarning("User {userId} password validation failed: {errors}.", user.Id, string.Join(";", errors.Select(e => e.Code))); return IdentityResult.Failed(errors.ToArray()); } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs index b91ac61f7f32..c501122474b6 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs @@ -6,7 +6,6 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; -using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; @@ -14,23 +13,27 @@ public class SetInitialMasterPasswordCommand : ISetInitialMasterPasswordCommand { private readonly IUserService _userService; private readonly IUserRepository _userRepository; + private readonly IMasterPasswordService _masterPasswordService; private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; - private readonly IPasswordHasher _passwordHasher; private readonly IEventService _eventService; - public SetInitialMasterPasswordCommand(IUserService userService, IUserRepository userRepository, - IAcceptOrgUserCommand acceptOrgUserCommand, IOrganizationUserRepository organizationUserRepository, - IOrganizationRepository organizationRepository, IPasswordHasher passwordHasher, + public SetInitialMasterPasswordCommand( + IUserService userService, + IUserRepository userRepository, + IMasterPasswordService masterPasswordService, + IAcceptOrgUserCommand acceptOrgUserCommand, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, IEventService eventService) { _userService = userService; _userRepository = userRepository; + _masterPasswordService = masterPasswordService; _acceptOrgUserCommand = acceptOrgUserCommand; _organizationUserRepository = organizationUserRepository; _organizationRepository = organizationRepository; - _passwordHasher = passwordHasher; _eventService = eventService; } @@ -63,15 +66,13 @@ public async Task SetInitialMasterPasswordAsync(User user, throw new BadRequestException("User not found within organization."); } - // Hash the provided user master password authentication hash on the server side - var serverSideHashedMasterPasswordAuthenticationHash = _passwordHasher.HashPassword(user, - masterPasswordDataModel.MasterPasswordAuthentication.MasterPasswordAuthenticationHash); + var updateUserData = + _masterPasswordService.BuildTransactionForSetInitialMasterPasswordAsync( + user, + masterPasswordDataModel.ToSetInitialPasswordData()); - var setMasterPasswordTask = _userRepository.SetMasterPassword(user.Id, - masterPasswordDataModel.MasterPasswordUnlock, serverSideHashedMasterPasswordAuthenticationHash, - masterPasswordDataModel.MasterPasswordHint); await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, masterPasswordDataModel.AccountKeys, - [setMasterPasswordTask]); + [updateUserData]); await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); From 909ea5a78853319240201bec4890bb7ed1d44c0f Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Wed, 8 Apr 2026 22:17:54 -0400 Subject: [PATCH 08/20] feat(master-password): Master Password Service - Lots of misc changes around the request models and tightening them up to all be shaped similarly --- .../Auth/Controllers/AccountsController.cs | 2 +- .../Controllers/EmergencyAccessController.cs | 2 +- .../SetInitialPasswordRequestModel.cs | 4 +- ...pdateTdeOffboardingPasswordRequestModel.cs | 41 ++++++++++--- .../Request/EmergencyAccessRequestModels.cs | 15 ++++- ...ganizationUserResetPasswordRequestModel.cs | 12 +++- .../v2/AdminRecoverAccountCommand.cs | 2 +- .../v2/RecoverAccountRequest.cs | 31 +++++++++- .../EmergencyAccess/EmergencyAccessService.cs | 2 +- .../ITdeOffboardingPasswordCommand.cs | 4 ++ .../TdeOffboardingPasswordCommand.cs | 60 +++++++++++++++++++ ...ishSsoJitProvisionMasterPasswordCommand.cs | 2 +- .../Interfaces/IMasterPasswordService.cs | 2 +- .../MasterPasswordService.cs | 8 +-- .../SetInitialMasterPasswordCommandV1.cs | 1 + .../TdeSetPasswordCommand.cs | 13 ++-- .../SetInitialPasswordRequestModelTests.cs | 8 +-- 17 files changed, 170 insertions(+), 39 deletions(-) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 3cee44befacb..1ddb8bcb4f60 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -222,7 +222,7 @@ public async Task PostSetPasswordAsync([FromBody] SetInitialPasswordRequestModel throw new UnauthorizedAccessException(); } - if (model.IsV2Request()) + if (model.RequestHasNewDataTypes()) { if (model.IsTdeSetPasswordRequest()) { diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index 34ddb811b3f1..4c6e719c34b4 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -175,7 +175,7 @@ public async Task Password(Guid id, [FromBody] EmergencyAccessPasswordRequestMod var user = await _userService.GetUserByPrincipalAsync(User); // Unwind this with PM-33141 to only use the new payload - if (model.HasNewPayloads()) + if (model.RequestHasNewDataTypes()) { await _emergencyAccessService.FinishRecoveryTakeoverAsync(id, user, model.UnlockData!.ToData(), model.AuthenticationData!.ToData()); } diff --git a/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs index 37a7901fee50..34639ad25442 100644 --- a/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs @@ -58,7 +58,7 @@ public User ToUser(User existingUser) public IEnumerable Validate(ValidationContext validationContext) { - if (IsV2Request()) + if (RequestHasNewDataTypes()) { // V2 registration @@ -134,7 +134,7 @@ public IEnumerable Validate(ValidationContext validationContex } } - public bool IsV2Request() + public bool RequestHasNewDataTypes() { // AccountKeys can be null for TDE users, so we don't check that here return MasterPasswordAuthentication != null && MasterPasswordUnlock != null; diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs index e99c9907562e..72b69d2f8d78 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs @@ -1,17 +1,40 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Api.Request; namespace Bit.Api.Auth.Models.Request.Accounts; -public class UpdateTdeOffboardingPasswordRequestModel +public class UpdateTdeOffboardingPasswordRequestModel : IValidatableObject { - [Required] + [Obsolete("To be removed in PM-33141")] [StringLength(300)] - public string NewMasterPasswordHash { get; set; } + public string? NewMasterPasswordHash { get; set; } + [Obsolete("To be removed in PM-33141")] [Required] - public string Key { get; set; } + public string? Key { get; set; } [StringLength(50)] - public string MasterPasswordHint { get; set; } + public string? MasterPasswordHint { get; set; } + + public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } + public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } + + // To be removed in PM-33141 + public IEnumerable Validate(ValidationContext validationContext) + { + var hasNewPayloads = AuthenticationData is not null && UnlockData is not null; + var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; + + if (hasNewPayloads && hasLegacyPayloads) + { + yield return new ValidationResult( + "Cannot provide both new payloads (UnlockData/AuthenticationData) and legacy payloads (NewMasterPasswordHash/Key).", + [nameof(AuthenticationData), nameof(UnlockData), nameof(NewMasterPasswordHash), nameof(Key)]); + } + + if (!hasNewPayloads && !hasLegacyPayloads) + { + yield return new ValidationResult( + "Must provide either new payloads (UnlockData/AuthenticationData) or legacy payloads (NewMasterPasswordHash/Key).", + [nameof(AuthenticationData), nameof(UnlockData), nameof(NewMasterPasswordHash), nameof(Key)]); + } + } } diff --git a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs index e96c68db9590..dece8cf75138 100644 --- a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs +++ b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs @@ -43,22 +43,26 @@ public EmergencyAccess ToEmergencyAccess(EmergencyAccess existingEmergencyAccess public class EmergencyAccessPasswordRequestModel : IValidatableObject { + [Obsolete("To be removed in PM-33141")] [StringLength(300)] public string? NewMasterPasswordHash { get; set; } + [Obsolete("To be removed in PM-33141")] public string? Key { get; set; } public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } - public bool HasNewPayloads() + // To be removed in PM-33141 + public bool RequestHasNewDataTypes() { return UnlockData is not null && AuthenticationData is not null; } + // To be removed in PM-33141 public IEnumerable Validate(ValidationContext validationContext) { var hasNewPayloads = UnlockData is not null && AuthenticationData is not null; - var hasLegacyPayloads = NewMasterPasswordHash is not null || Key is not null; + var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; if (hasNewPayloads && hasLegacyPayloads) { @@ -66,6 +70,13 @@ public IEnumerable Validate(ValidationContext validationContex "Cannot provide both new payloads (UnlockData/AuthenticationData) and legacy payloads (NewMasterPasswordHash/Key).", [nameof(UnlockData), nameof(AuthenticationData), nameof(NewMasterPasswordHash), nameof(Key)]); } + + if (!hasNewPayloads && !hasLegacyPayloads) + { + yield return new ValidationResult( + "Must provide either new payloads (UnlockData/AuthenticationData) or legacy payloads (NewMasterPasswordHash/Key).", + [nameof(UnlockData), nameof(AuthenticationData), nameof(NewMasterPasswordHash), nameof(Key)]); + } } } diff --git a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs index 4a8e0e80f3ca..e54838402d72 100644 --- a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs @@ -10,8 +10,10 @@ public class OrganizationUserResetPasswordRequestModel : IValidatableObject public bool ResetMasterPassword { get; set; } public bool ResetTwoFactor { get; set; } + [Obsolete("To be removed in PM-33141")] [StringLength(300)] public string? NewMasterPasswordHash { get; set; } + [Obsolete("To be removed in PM-33141")] public string? Key { get; set; } public MasterPasswordUnlockDataRequestModel? UnlockData; @@ -29,10 +31,11 @@ public class OrganizationUserResetPasswordRequestModel : IValidatableObject AuthenticationData = AuthenticationData, }; + // To be removed in PM-33141 public IEnumerable Validate(ValidationContext validationContext) { var hasNewPayloads = UnlockData is not null && AuthenticationData is not null; - var hasLegacyPayloads = NewMasterPasswordHash is not null || Key is not null; + var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; if (hasNewPayloads && hasLegacyPayloads) { @@ -40,5 +43,12 @@ public IEnumerable Validate(ValidationContext validationContex "Cannot provide both new payloads (UnlockData/AuthenticationData) and legacy payloads (NewMasterPasswordHash/Key).", [nameof(UnlockData), nameof(AuthenticationData), nameof(NewMasterPasswordHash), nameof(Key)]); } + + if (!hasNewPayloads && !hasLegacyPayloads) + { + yield return new ValidationResult( + "Must provide either new payloads (UnlockData/AuthenticationData) or legacy payloads (NewMasterPasswordHash/Key).", + [nameof(UnlockData), nameof(AuthenticationData), nameof(NewMasterPasswordHash), nameof(Key)]); + } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs index 323498c737e6..192df378be4b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs @@ -60,7 +60,7 @@ public async Task RecoverAccountAsync(RecoverAccountRequest reque if (request.ResetMasterPassword) { // Unwind this with PM-33141 to only use the new payload - if (request.HasNewPayloads()) + if (request.RequestHasNewDataTypes()) { var result = await HandlePayloadsWithUnlockAndAuthenticationDataAsync(user, request); if (result.IsError) diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/RecoverAccountRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/RecoverAccountRequest.cs index 3781899ea3a6..8e654d996cab 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/RecoverAccountRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/RecoverAccountRequest.cs @@ -1,9 +1,10 @@ -using Bit.Core.Entities; +using System.ComponentModel.DataAnnotations; +using Bit.Core.Entities; using Bit.Core.KeyManagement.Models.Api.Request; namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery.v2; -public record RecoverAccountRequest +public record RecoverAccountRequest : IValidatableObject { public required Guid OrgId { get; init; } public required OrganizationUser OrganizationUser { get; init; } @@ -13,11 +14,35 @@ public record RecoverAccountRequest public MasterPasswordUnlockDataRequestModel? UnlockData; public MasterPasswordAuthenticationDataRequestModel? AuthenticationData; + [Obsolete("To be removed in PM-33141")] public string? NewMasterPasswordHash { get; init; } + [Obsolete("To be removed in PM-33141")] public string? Key { get; init; } - public bool HasNewPayloads() + // To be removed in PM-33141 + public bool RequestHasNewDataTypes() { return UnlockData is not null && AuthenticationData is not null; } + + // To be removed in PM-33141 + public IEnumerable Validate(ValidationContext validationContext) + { + var hasNewPayloads = UnlockData is not null && AuthenticationData is not null; + var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; + + if (hasNewPayloads && hasLegacyPayloads) + { + yield return new ValidationResult( + "Cannot provide both new payloads (UnlockData/AuthenticationData) and legacy payloads (NewMasterPasswordHash/Key).", + [nameof(UnlockData), nameof(AuthenticationData), nameof(NewMasterPasswordHash), nameof(Key)]); + } + + if (!hasNewPayloads && !hasLegacyPayloads) + { + yield return new ValidationResult( + "Must provide either new payloads (UnlockData/AuthenticationData) or legacy payloads (NewMasterPasswordHash/Key).", + [nameof(UnlockData), nameof(AuthenticationData), nameof(NewMasterPasswordHash), nameof(Key)]); + } + } } diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs index 0dc2a19eef43..445398753c15 100644 --- a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs @@ -372,7 +372,7 @@ public async Task> GetPoliciesAsync(Guid emergencyAccessId, return (emergencyAccess, grantor); } - [Obsolete] + [Obsolete("To be removed in PM-33141")] public async Task FinishRecoveryTakeoverAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); diff --git a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs index 1ff64ffabb79..67964b6115ec 100644 --- a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs @@ -1,4 +1,5 @@ using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; @@ -11,4 +12,7 @@ public interface ITdeOffboardingPasswordCommand { public Task UpdateTdeOffboardingPasswordAsync(User user, string masterPassword, string key, string orgSsoIdentifier); + + public Task UpdateTdeOffboardingPasswordAsync(User user, MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, string orgSsoIdentifier); } diff --git a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs index 719ff9ce9dcc..7470387055b0 100644 --- a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs @@ -1,8 +1,11 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -14,6 +17,7 @@ public class TdeOffboardingPasswordCommand : ITdeOffboardingPasswordCommand { private readonly IUserService _userService; private readonly IUserRepository _userRepository; + private readonly IMasterPasswordService _masterPasswordService; private readonly IEventService _eventService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ISsoUserRepository _ssoUserRepository; @@ -24,6 +28,7 @@ public class TdeOffboardingPasswordCommand : ITdeOffboardingPasswordCommand public TdeOffboardingPasswordCommand( IUserService userService, IUserRepository userRepository, + IMasterPasswordService masterPasswordService, IEventService eventService, IOrganizationUserRepository organizationUserRepository, ISsoUserRepository ssoUserRepository, @@ -32,6 +37,7 @@ public TdeOffboardingPasswordCommand( { _userService = userService; _userRepository = userRepository; + _masterPasswordService = masterPasswordService; _eventService = eventService; _organizationUserRepository = organizationUserRepository; _ssoUserRepository = ssoUserRepository; @@ -39,6 +45,7 @@ public TdeOffboardingPasswordCommand( _pushService = pushService; } + [Obsolete("To be removed in PM-33141")] public async Task UpdateTdeOffboardingPasswordAsync(User user, string newMasterPassword, string key, string hint) { if (string.IsNullOrWhiteSpace(newMasterPassword)) @@ -97,4 +104,57 @@ public async Task UpdateTdeOffboardingPasswordAsync(User user, s return IdentityResult.Success; } + public async Task UpdateTdeOffboardingPasswordAsync( + User user, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, + string hint) + { + var orgUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(user.Id); + orgUserDetails = orgUserDetails.Where(x => x.UseSso).ToList(); + if (orgUserDetails.Count == 0) + { + throw new BadRequestException("User is not part of any organization that has SSO enabled."); + } + + var orgSSOUsers = await Task.WhenAll(orgUserDetails.Select(async x => await _ssoUserRepository.GetByUserIdOrganizationIdAsync(x.OrganizationId, user.Id))); + if (orgSSOUsers.Length != 1) + { + throw new BadRequestException("User is part of no or multiple SSO configurations."); + } + + var orgUser = orgUserDetails.First(); + var orgSSOConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgUser.OrganizationId); + if (orgSSOConfig == null) + { + throw new BadRequestException("Organization SSO configuration not found."); + } + + if (orgSSOConfig.GetData().MemberDecryptionType != Enums.MemberDecryptionType.MasterPassword) + { + throw new BadRequestException("Organization SSO Member Decryption Type is not Master Password."); + } + + // We only want to be setting an initial master password here, if they already have one, + // we are in an error state. + var result = await _masterPasswordService.OnlyMutateUserSetInitialMasterPasswordAsync(user, new SetInitialPasswordData + { + MasterPasswordUnlock = unlockData, + MasterPasswordAuthentication = authenticationData, + MasterPasswordHint = hint + }); + if (!result.Succeeded) + { + return result; + } + + // Side effect of running TDE offboarding, we want to force reset + user.ForcePasswordReset = false; + + await _userRepository.ReplaceAsync(user); + await _eventService.LogUserEventAsync(user.Id, EventType.User_TdeOffboardingPasswordSet); + await _pushService.PushLogOutAsync(user.Id); + + return IdentityResult.Success; + } } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs index a831d81ac1f1..4d80c75b1018 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs @@ -67,7 +67,7 @@ public async Task FinishProvisionAsync(User user, } var updateUserData = - _masterPasswordService.BuildTransactionForSetInitialMasterPasswordAsync( + _masterPasswordService.BuildTransactionForSetInitialMasterPassword( user, masterPasswordDataModel.ToSetInitialPasswordData()); diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs index bd668516049b..3a49ab7e1edd 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs @@ -99,7 +99,7 @@ public interface IMasterPasswordService /// An delegate suitable for inclusion in a batch passed to /// . /// - UpdateUserData BuildTransactionForSetInitialMasterPasswordAsync(User user, SetInitialPasswordData setInitialPasswordData); + UpdateUserData BuildTransactionForSetInitialMasterPassword(User user, SetInitialPasswordData setInitialPasswordData); /// /// Applies a new master password over the user's existing one, mutating the diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs index f89e360de908..91823c988462 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs @@ -23,8 +23,6 @@ public async Task OnlyMutateEitherUpdateExistingPasswordOrSetIni SetInitialOrChangeExistingPasswordData setOrUpdatePasswordData) { IdentityResult mutationResult; - // We can recover an account for users who both have a master password and - // those who do not. TDE users can be recovered and will not have a password if (user.HasMasterPassword()) { mutationResult = await OnlyMutateUserUpdateExistingMasterPasswordAsync( @@ -57,7 +55,7 @@ public async Task OnlyMutateUserSetInitialMasterPasswordAsync( return result; } - // Set kdf data on the user. + // Set kdf data on the user user.Key = setInitialData.MasterPasswordUnlock.MasterKeyWrappedUserKey; user.Kdf = setInitialData.MasterPasswordUnlock.Kdf.KdfType; user.KdfIterations = setInitialData.MasterPasswordUnlock.Kdf.Iterations; @@ -85,7 +83,7 @@ public async Task SetInitialMasterPasswordAndSaveUserAsync( User user, SetInitialPasswordData setInitialData) { - // No need to validate because we will validate in the sibling call here. + // No need to validate because we will validate in the sibling call here var result = await OnlyMutateUserSetInitialMasterPasswordAsync(user, setInitialData); if (!result.Succeeded) { @@ -96,7 +94,7 @@ public async Task SetInitialMasterPasswordAndSaveUserAsync( return IdentityResult.Success; } - public UpdateUserData BuildTransactionForSetInitialMasterPasswordAsync( + public UpdateUserData BuildTransactionForSetInitialMasterPassword( User user, SetInitialPasswordData setInitialData) { diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1.cs index df5f0d02f7b3..cea36702a4f8 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1.cs @@ -42,6 +42,7 @@ public SetInitialMasterPasswordCommandV1( _organizationRepository = organizationRepository; } + [Obsolete("To be removed in PM-33141")] public async Task SetInitialMasterPasswordAsync(User user, string masterPassword, string key, string orgSsoIdentifier) { diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs index a8eaa1a2ea90..8314d645110f 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs @@ -6,7 +6,6 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; -using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; @@ -16,19 +15,19 @@ public class TdeSetPasswordCommand : ITdeSetPasswordCommand private readonly IMasterPasswordService _masterPasswordService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; - private readonly IPasswordHasher _passwordHasher; private readonly IEventService _eventService; - public TdeSetPasswordCommand(IUserRepository userRepository, + public TdeSetPasswordCommand( + IUserRepository userRepository, IMasterPasswordService masterPasswordService, - IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, - IPasswordHasher passwordHasher, IEventService eventService) + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IEventService eventService) { _userRepository = userRepository; _masterPasswordService = masterPasswordService; _organizationUserRepository = organizationUserRepository; _organizationRepository = organizationRepository; - _passwordHasher = passwordHasher; _eventService = eventService; } @@ -53,7 +52,7 @@ public async Task SetMasterPasswordAsync(User user, SetInitialMasterPasswordData throw new BadRequestException("User not found within organization."); } - var setMasterPasswordTask = _masterPasswordService.BuildTransactionForSetInitialMasterPasswordAsync(user, + var setMasterPasswordTask = _masterPasswordService.BuildTransactionForSetInitialMasterPassword(user, new SetInitialPasswordData { MasterPasswordUnlock = masterPasswordDataModel.MasterPasswordUnlock, diff --git a/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs index 97e69dacbca4..39d24f11b0a5 100644 --- a/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs +++ b/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs @@ -343,7 +343,7 @@ public void IsV2Request_WithV2Properties_ReturnsTrue(string orgIdentifier) }; // Act - var result = model.IsV2Request(); + var result = model.RequestHasNewDataTypes(); // Assert Assert.True(result); @@ -370,7 +370,7 @@ public void IsV2Request_WithoutMasterPasswordAuthentication_ReturnsFalse(string }; // Act - var result = model.IsV2Request(); + var result = model.RequestHasNewDataTypes(); // Assert Assert.False(result); @@ -397,7 +397,7 @@ public void IsV2Request_WithoutMasterPasswordUnlock_ReturnsFalse(string orgIdent }; // Act - var result = model.IsV2Request(); + var result = model.RequestHasNewDataTypes(); // Assert Assert.False(result); @@ -418,7 +418,7 @@ public void IsV2Request_WithV1Properties_ReturnsFalse(string orgIdentifier) }; // Act - var result = model.IsV2Request(); + var result = model.RequestHasNewDataTypes(); // Assert Assert.False(result); From 3f6a1e0bf11f918886be8241ffe5b59d131d104e Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Thu, 9 Apr 2026 10:00:46 -0400 Subject: [PATCH 09/20] feat(master-password): Master Password Service - Removed commands and updated the changekdf function to use the master password service. --- .../Request/Accounts/PasswordRequestModel.cs | 30 +++++++++-- ...pdateTdeOffboardingPasswordRequestModel.cs | 7 ++- ...ganizationUserResetPasswordRequestModel.cs | 6 +++ .../ISetInitialMasterPasswordStateCommand.cs | 8 --- .../IUpdateMasterPasswordStateCommand.cs | 8 --- .../MasterPasswordService.cs | 34 ++++++++----- .../SetInitialMasterPasswordStateCommand.cs | 17 ------- .../UpdateMasterPasswordStateCommand.cs | 17 ------- .../UserServiceCollectionExtensions.cs | 2 - .../Kdf/Implementations/ChangeKdfCommand.cs | 51 +++++++------------ 10 files changed, 75 insertions(+), 105 deletions(-) delete mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordStateCommand.cs delete mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IUpdateMasterPasswordStateCommand.cs delete mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommand.cs delete mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommand.cs diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs index ab8c727852dd..50c6630f0895 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs @@ -5,15 +5,35 @@ namespace Bit.Api.Auth.Models.Request.Accounts; public class PasswordRequestModel : SecretVerificationRequestModel { - [Required] [StringLength(300)] - public required string NewMasterPasswordHash { get; set; } + public string? NewMasterPasswordHash { get; set; } [StringLength(50)] public string? MasterPasswordHint { get; set; } - [Required] - public required string Key { get; set; } + public string? Key { get; set; } - // Note: These will eventually become required, but not all consumers are moved over yet. public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } + + // To be removed in PM-33141 + public override IEnumerable Validate(ValidationContext validationContext) + { + yield return base.Validate(validationContext); + + var hasNewPayloads = AuthenticationData is not null && UnlockData is not null; + var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; + + if (hasNewPayloads && hasLegacyPayloads) + { + yield return new ValidationResult( + "Cannot provide both new payloads (UnlockData/AuthenticationData) and legacy payloads (NewMasterPasswordHash/Key).", + [nameof(AuthenticationData), nameof(UnlockData), nameof(NewMasterPasswordHash), nameof(Key)]); + } + + if (!hasNewPayloads && !hasLegacyPayloads) + { + yield return new ValidationResult( + "Must provide either new payloads (UnlockData/AuthenticationData) or legacy payloads (NewMasterPasswordHash/Key).", + [nameof(AuthenticationData), nameof(UnlockData), nameof(NewMasterPasswordHash), nameof(Key)]); + } + } } diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs index 72b69d2f8d78..e08996b1de65 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs @@ -9,7 +9,6 @@ public class UpdateTdeOffboardingPasswordRequestModel : IValidatableObject [StringLength(300)] public string? NewMasterPasswordHash { get; set; } [Obsolete("To be removed in PM-33141")] - [Required] public string? Key { get; set; } [StringLength(50)] public string? MasterPasswordHint { get; set; } @@ -17,6 +16,12 @@ public class UpdateTdeOffboardingPasswordRequestModel : IValidatableObject public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } + // To be removed in PM-33141 + public bool RequestHasNewDataTypes() + { + return UnlockData is not null && AuthenticationData is not null; + } + // To be removed in PM-33141 public IEnumerable Validate(ValidationContext validationContext) { diff --git a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs index e54838402d72..e4db46456613 100644 --- a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs @@ -31,6 +31,12 @@ public class OrganizationUserResetPasswordRequestModel : IValidatableObject AuthenticationData = AuthenticationData, }; + // To be removed in PM-33141 + public bool RequestHasNewDataTypes() + { + return UnlockData is not null && AuthenticationData is not null; + } + // To be removed in PM-33141 public IEnumerable Validate(ValidationContext validationContext) { diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordStateCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordStateCommand.cs deleted file mode 100644 index 845883a19e1e..000000000000 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordStateCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Bit.Core.Entities; - -namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; - -public interface ISetInitialMasterPasswordStateCommand -{ - Task ExecuteAsync(User user); -} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IUpdateMasterPasswordStateCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IUpdateMasterPasswordStateCommand.cs deleted file mode 100644 index 8cff4c460631..000000000000 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IUpdateMasterPasswordStateCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Bit.Core.Entities; - -namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; - -public interface IUpdateMasterPasswordStateCommand -{ - Task ExecuteAsync(User user); -} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs index 91823c988462..c073fd36312d 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs @@ -13,11 +13,16 @@ public class MasterPasswordService( IPasswordHasher passwordHasher, IEnumerable> passwordValidators, UserManager userManager, - ILogger logger, - ISetInitialMasterPasswordStateCommand setInitialMasterPasswordStateCommand, - IUpdateMasterPasswordStateCommand updateMasterPasswordStateCommand) + ILogger logger) : IMasterPasswordService { + private readonly IUserRepository _userRepository = userRepository; + private readonly TimeProvider _timeProvider = timeProvider; + private readonly IPasswordHasher _passwordHasher = passwordHasher; + private readonly IEnumerable> _passwordValidators = passwordValidators; + private readonly UserManager _userManager = userManager; + private readonly ILogger _logger = logger; + public async Task OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( User user, SetInitialOrChangeExistingPasswordData setOrUpdatePasswordData) @@ -72,7 +77,7 @@ public async Task OnlyMutateUserSetInitialMasterPasswordAsync( } // Update time markers on the user - var now = timeProvider.GetUtcNow().UtcDateTime; + var now = _timeProvider.GetUtcNow().UtcDateTime; user.LastPasswordChangeDate = now; user.RevisionDate = user.AccountRevisionDate = now; @@ -90,7 +95,8 @@ public async Task SetInitialMasterPasswordAndSaveUserAsync( return result; } - await setInitialMasterPasswordStateCommand.ExecuteAsync(user); + await _userRepository.ReplaceAsync(user); + return IdentityResult.Success; } @@ -101,10 +107,10 @@ public UpdateUserData BuildTransactionForSetInitialMasterPassword( setInitialData.ValidateDataForUser(user); // Hash the provided user master password authentication hash on the server side - var serverSideHashedMasterPasswordAuthenticationHash = passwordHasher.HashPassword(user, + var serverSideHashedMasterPasswordAuthenticationHash = _passwordHasher.HashPassword(user, setInitialData.MasterPasswordAuthentication.MasterPasswordAuthenticationHash); - var setMasterPasswordTask = userRepository.SetMasterPassword(user.Id, + var setMasterPasswordTask = _userRepository.SetMasterPassword(user.Id, setInitialData.MasterPasswordUnlock, serverSideHashedMasterPasswordAuthenticationHash, setInitialData.MasterPasswordHint); @@ -129,7 +135,7 @@ public async Task OnlyMutateUserUpdateExistingMasterPasswordAsyn return result; } - var now = timeProvider.GetUtcNow().UtcDateTime; + var now = _timeProvider.GetUtcNow().UtcDateTime; user.Key = updateExistingData.MasterPasswordUnlock.MasterKeyWrappedUserKey; @@ -152,7 +158,8 @@ public async Task UpdateExistingMasterPasswordAndSaveAsync( return result; } - await updateMasterPasswordStateCommand.ExecuteAsync(user); + await _userRepository.ReplaceAsync(user); + return IdentityResult.Success; } @@ -168,7 +175,7 @@ private async Task UpdateExistingPasswordHashAsync(User user, st } } - user.MasterPassword = passwordHasher.HashPassword(user, newPassword); + user.MasterPassword = _passwordHasher.HashPassword(user, newPassword); if (refreshStamp) { user.SecurityStamp = Guid.NewGuid().ToString(); @@ -177,12 +184,13 @@ private async Task UpdateExistingPasswordHashAsync(User user, st return IdentityResult.Success; } + // Taken from the private async Task ValidatePasswordInternalAsync(User user, string password) { var errors = new List(); - foreach (var v in passwordValidators) + foreach (var v in _passwordValidators) { - var result = await v.ValidateAsync(userManager, user, password); + var result = await v.ValidateAsync(_userManager, user, password); if (!result.Succeeded) { errors.AddRange(result.Errors); @@ -191,7 +199,7 @@ private async Task ValidatePasswordInternalAsync(User user, stri if (errors.Count > 0) { - logger.LogWarning("User {userId} password validation failed: {errors}.", user.Id, + _logger.LogWarning("User {userId} password validation failed: {errors}.", user.Id, string.Join(";", errors.Select(e => e.Code))); return IdentityResult.Failed(errors.ToArray()); } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommand.cs deleted file mode 100644 index 0fcb7a278b8a..000000000000 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; -using Bit.Core.Entities; -using Bit.Core.Repositories; - -namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; - -public class SetInitialMasterPasswordStateCommand : ISetInitialMasterPasswordStateCommand -{ - private readonly IUserRepository _userRepository; - - public SetInitialMasterPasswordStateCommand(IUserRepository userRepository) - { - _userRepository = userRepository; - } - - public Task ExecuteAsync(User user) => _userRepository.ReplaceAsync(user); -} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommand.cs deleted file mode 100644 index e508b2eb1e10..000000000000 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; -using Bit.Core.Entities; -using Bit.Core.Repositories; - -namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; - -public class UpdateMasterPasswordStateCommand : IUpdateMasterPasswordStateCommand -{ - private readonly IUserRepository _userRepository; - - public UpdateMasterPasswordStateCommand(IUserRepository userRepository) - { - _userRepository = userRepository; - } - - public Task ExecuteAsync(User user) => _userRepository.ReplaceAsync(user); -} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 4515a41e4148..a115f1c73657 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -56,8 +56,6 @@ private static void AddUserPasswordCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); } private static void AddTdeOffboardingPasswordCommands(this IServiceCollection services) diff --git a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs index 83e47c4931d0..cfb9a95d7231 100644 --- a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs +++ b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs @@ -1,4 +1,6 @@ -using Bit.Core.Entities; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Models.Data; @@ -17,17 +19,19 @@ public class ChangeKdfCommand : IChangeKdfCommand private readonly IUserService _userService; private readonly IPushNotificationService _pushService; private readonly IUserRepository _userRepository; + private readonly IMasterPasswordService _masterPasswordService; private readonly IdentityErrorDescriber _identityErrorDescriber; private readonly ILogger _logger; private readonly IFeatureService _featureService; public ChangeKdfCommand(IUserService userService, IPushNotificationService pushService, - IUserRepository userRepository, IdentityErrorDescriber describer, ILogger logger, - IFeatureService featureService) + IUserRepository userRepository, IMasterPasswordService masterPasswordService, IdentityErrorDescriber describer, + ILogger logger, IFeatureService featureService) { _userService = userService; _pushService = pushService; _userRepository = userRepository; + _masterPasswordService = masterPasswordService; _identityErrorDescriber = describer; _logger = logger; _featureService = featureService; @@ -62,42 +66,21 @@ public async Task ChangeKdfAsync(User user, string masterPasswor var logoutOnKdfChange = !_featureService.IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange); - // Update the user with the new KDF settings - // This updates the authentication data and unlock data for the user separately. Currently these still - // use shared values for KDF settings and salt. - // The authentication hash, and the unlock data each are dependent on: - // - The master password (entered by the user every time) - // - The KDF settings (iterations, memory, parallelism) - // - The salt - // These combinations - (password, authentication hash, KDF settings, salt) and (password, unlock data, KDF settings, salt) - // must remain consistent to unlock correctly. + // KM do we want this to be a new call in the master password service for ChangeKdf? + var updateExisingPasswordResult = await _masterPasswordService.UpdateExistingMasterPasswordAndSaveAsync(user, + new UpdateExistingPasswordData + { + MasterPasswordUnlock = unlockData, + MasterPasswordAuthentication = authenticationData, + RefreshStamp = logoutOnKdfChange + }); - // Authentication - // Note: This mutates the user but does not yet save it to DB. That is done atomically, later. - // This entire operation MUST be atomic to prevent a user from being locked out of their account. - // Salt is ensured to be the same as unlock data, and the value stored in the account and not updated. - // KDF is ensured to be the same as unlock data above and updated below. - var result = await _userService.UpdatePasswordHash(user, authenticationData.MasterPasswordAuthenticationHash, - refreshStamp: logoutOnKdfChange); - if (!result.Succeeded) + if (!updateExisingPasswordResult.Succeeded) { _logger.LogWarning("Change KDF failed for user {userId}.", user.Id); - return result; + return updateExisingPasswordResult; } - // Salt is ensured to be the same as authentication data, and the value stored in the account, and is not updated. - // Kdf - These will be seperated in the future, but for now are ensured to be the same as authentication data above. - user.Key = unlockData.MasterKeyWrappedUserKey; - user.Kdf = unlockData.Kdf.KdfType; - user.KdfIterations = unlockData.Kdf.Iterations; - user.KdfMemory = unlockData.Kdf.Memory; - user.KdfParallelism = unlockData.Kdf.Parallelism; - - var now = DateTime.UtcNow; - user.RevisionDate = user.AccountRevisionDate = now; - user.LastKdfChangeDate = now; - - await _userRepository.ReplaceAsync(user); if (logoutOnKdfChange) { await _pushService.PushLogOutAsync(user.Id); From 8385caee605b5f376cb9ef87178382d9d4c65648 Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Thu, 9 Apr 2026 10:01:55 -0400 Subject: [PATCH 10/20] feat(master-password): Master Password Service - Fixed validation error --- src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs index 50c6630f0895..5f04218411e3 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs @@ -17,7 +17,10 @@ public class PasswordRequestModel : SecretVerificationRequestModel // To be removed in PM-33141 public override IEnumerable Validate(ValidationContext validationContext) { - yield return base.Validate(validationContext); + foreach (var result in base.Validate(validationContext)) + { + yield return result; + } var hasNewPayloads = AuthenticationData is not null && UnlockData is not null; var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; From cb98256f160e2bd8cdeaa52a48a2e1ad80efb1b7 Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Thu, 9 Apr 2026 11:35:00 -0400 Subject: [PATCH 11/20] feat(master-password): Master Password Service - Made changed to the request models and the master password service. --- src/Api/Auth/Controllers/AccountsController.cs | 11 +++++++++++ .../Models/Request/Accounts/PasswordRequestModel.cs | 4 +++- .../Accounts/UpdateTempPasswordRequestModel.cs | 7 ++----- .../Interfaces/ITdeOffboardingPasswordCommand.cs | 4 ++-- .../Interfaces/IMasterPasswordService.cs | 4 ++-- .../UserMasterPassword/MasterPasswordService.cs | 10 ++++------ 6 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 1ddb8bcb4f60..df7d86b974c4 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -669,6 +669,17 @@ public async Task PutUpdateTdePasswordAsync([FromBody] UpdateTdeOffboardingPassw throw new UnauthorizedAccessException(); } + + var result; + if (model.RequestHasNewDataTypes()) + { + result = await _tdeOffboardingPasswordCommand.UpdateTdeOffboardingPasswordAsync(user, model.UnlockData!.ToData(), model.AuthenticationData!.ToData(), model.MasterPasswordHint); + } + else + { + result = await _tdeOffboardingPasswordCommand.UpdateTdeOffboardingPasswordAsync(user, model.NewMasterPasswordHash, model.Key, model.MasterPasswordHint); + } + var result = await _tdeOffboardingPasswordCommand.UpdateTdeOffboardingPasswordAsync(user, model.NewMasterPasswordHash, model.Key, model.MasterPasswordHint); if (result.Succeeded) { diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs index 5f04218411e3..26fbc9bb891a 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs @@ -5,11 +5,13 @@ namespace Bit.Api.Auth.Models.Request.Accounts; public class PasswordRequestModel : SecretVerificationRequestModel { + [Obsolete("To be removed in PM-33141")] [StringLength(300)] public string? NewMasterPasswordHash { get; set; } + [Obsolete("To be removed in PM-33141")] + public string? Key { get; set; } [StringLength(50)] public string? MasterPasswordHint { get; set; } - public string? Key { get; set; } public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs index e071726edf8f..a408df3d2243 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Api.Models.Request.Organizations; namespace Bit.Api.Auth.Models.Request.Accounts; @@ -9,5 +6,5 @@ namespace Bit.Api.Auth.Models.Request.Accounts; public class UpdateTempPasswordRequestModel : OrganizationUserResetPasswordRequestModel { [StringLength(50)] - public string MasterPasswordHint { get; set; } + public string? MasterPasswordHint { get; set; } } diff --git a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs index 67964b6115ec..c397f5382b51 100644 --- a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs @@ -11,8 +11,8 @@ namespace Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; public interface ITdeOffboardingPasswordCommand { public Task UpdateTdeOffboardingPasswordAsync(User user, string masterPassword, string key, - string orgSsoIdentifier); + string masterPasswordHint); public Task UpdateTdeOffboardingPasswordAsync(User user, MasterPasswordUnlockData unlockData, - MasterPasswordAuthenticationData authenticationData, string orgSsoIdentifier); + MasterPasswordAuthenticationData authenticationData, string masterPasswordHint); } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs index 3a49ab7e1edd..ab51bd25b66c 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs @@ -107,8 +107,8 @@ public interface IMasterPasswordService /// Use when the caller controls persistence. /// /// - /// The user object to mutate. Must already have a master password; must not be a Key Connector - /// user. KDF parameters and salt must be unchanged relative to the values in + /// The user object to mutate. Will not update a master password salt. Must already have a master password; + /// must not be a Key Connector user. KDF parameters and salt must be unchanged relative to the values in /// . Validated via /// . /// diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs index c073fd36312d..38f94afacf40 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs @@ -70,11 +70,8 @@ public async Task OnlyMutateUserSetInitialMasterPasswordAsync( // Set salt on the user user.MasterPasswordSalt = setInitialData.MasterPasswordUnlock.Salt; - // If we've passed in a hint then set it - if (setInitialData.MasterPasswordHint != null) - { - user.MasterPasswordHint = setInitialData.MasterPasswordHint; - } + // Always override the master password hint, even if it's null. + user.MasterPasswordHint = setInitialData.MasterPasswordHint; // Update time markers on the user var now = _timeProvider.GetUtcNow().UtcDateTime; @@ -139,7 +136,8 @@ public async Task OnlyMutateUserUpdateExistingMasterPasswordAsyn user.Key = updateExistingData.MasterPasswordUnlock.MasterKeyWrappedUserKey; - user.MasterPasswordSalt = updateExistingData.MasterPasswordUnlock.Salt; + // Always override the master password hint, even if it's null. + user.MasterPasswordHint = updateExistingData.MasterPasswordHint; user.LastPasswordChangeDate = now; user.RevisionDate = user.AccountRevisionDate = now; From 8fb2b7186c39af6af2db11fc564e7f12d9557893 Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Thu, 9 Apr 2026 15:50:30 -0400 Subject: [PATCH 12/20] feat(master-password): Master Password Service - Fixes to the self service password change. --- .../Auth/Controllers/AccountsController.cs | 108 +++++++++--------- .../Request/Accounts/PasswordRequestModel.cs | 15 ++- ...ganizationUserResetPasswordRequestModel.cs | 8 +- .../ITdeOffboardingPasswordCommand.cs | 4 +- .../MasterPasswordService.cs | 1 - src/Core/Services/IUserService.cs | 1 + .../Services/Implementations/UserService.cs | 1 + 7 files changed, 67 insertions(+), 71 deletions(-) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index df7d86b974c4..ebf2650648d8 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -14,6 +14,7 @@ using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -25,64 +26,45 @@ using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("accounts")] [Authorize(Policies.Application)] -public class AccountsController : Controller +public class AccountsController( + IOrganizationService organizationService, + IOrganizationUserRepository organizationUserRepository, + IProviderUserRepository providerUserRepository, + IUserService userService, + IMasterPasswordService masterPasswordService, + IPolicyService policyService, + IFinishSsoJitProvisionMasterPasswordCommand finishSsoJitProvisionMasterPasswordCommand, + ISetInitialMasterPasswordCommandV1 setInitialMasterPasswordCommandV1, + ITdeSetPasswordCommand tdeSetPasswordCommand, + ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IUserAccountKeysQuery userAccountKeysQuery, + ITwoFactorEmailService twoFactorEmailService, + IChangeKdfCommand changeKdfCommand, + IUserRepository userRepository) : Controller { - private readonly IOrganizationService _organizationService; - private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IProviderUserRepository _providerUserRepository; - private readonly IUserService _userService; - private readonly IPolicyService _policyService; - private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1; - private readonly IFinishSsoJitProvisionMasterPasswordCommand _finishSsoJitProvisionMasterPasswordCommand; - private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand; - private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; - private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; - private readonly IFeatureService _featureService; - private readonly IUserAccountKeysQuery _userAccountKeysQuery; - private readonly ITwoFactorEmailService _twoFactorEmailService; - private readonly IChangeKdfCommand _changeKdfCommand; - private readonly IUserRepository _userRepository; - - public AccountsController( - IOrganizationService organizationService, - IOrganizationUserRepository organizationUserRepository, - IProviderUserRepository providerUserRepository, - IUserService userService, - IPolicyService policyService, - IFinishSsoJitProvisionMasterPasswordCommand finishSsoJitProvisionMasterPasswordCommand, - ISetInitialMasterPasswordCommandV1 setInitialMasterPasswordCommandV1, - ITdeSetPasswordCommand tdeSetPasswordCommand, - ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, - ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IFeatureService featureService, - IUserAccountKeysQuery userAccountKeysQuery, - ITwoFactorEmailService twoFactorEmailService, - IChangeKdfCommand changeKdfCommand, - IUserRepository userRepository - ) - { - _organizationService = organizationService; - _organizationUserRepository = organizationUserRepository; - _providerUserRepository = providerUserRepository; - _userService = userService; - _policyService = policyService; - _finishSsoJitProvisionMasterPasswordCommand = finishSsoJitProvisionMasterPasswordCommand; - _setInitialMasterPasswordCommandV1 = setInitialMasterPasswordCommandV1; - _tdeSetPasswordCommand = tdeSetPasswordCommand; - _tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand; - _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; - _featureService = featureService; - _userAccountKeysQuery = userAccountKeysQuery; - _twoFactorEmailService = twoFactorEmailService; - _changeKdfCommand = changeKdfCommand; - _userRepository = userRepository; - } + private readonly IOrganizationService _organizationService = organizationService; + private readonly IOrganizationUserRepository _organizationUserRepository = organizationUserRepository; + private readonly IProviderUserRepository _providerUserRepository = providerUserRepository; + private readonly IUserService _userService = userService; + private readonly IMasterPasswordService _masterPasswordService = masterPasswordService; + private readonly IPolicyService _policyService = policyService; + private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1 = setInitialMasterPasswordCommandV1; + private readonly IFinishSsoJitProvisionMasterPasswordCommand _finishSsoJitProvisionMasterPasswordCommand = finishSsoJitProvisionMasterPasswordCommand; + private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand = tdeSetPasswordCommand; + private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; + private readonly IUserAccountKeysQuery _userAccountKeysQuery = userAccountKeysQuery; + private readonly ITwoFactorEmailService _twoFactorEmailService = twoFactorEmailService; + private readonly IChangeKdfCommand _changeKdfCommand = changeKdfCommand; + private readonly IUserRepository _userRepository = userRepository; [HttpPost("password-hint")] @@ -197,8 +179,23 @@ public async Task PostPassword([FromBody] PasswordRequestModel model) throw new UnauthorizedAccessException(); } - var result = await _userService.ChangePasswordAsync(user, model.MasterPasswordHash, - model.NewMasterPasswordHash, model.MasterPasswordHint, model.Key); + IdentityResult result; + if (model.RequestHasNewDataTypes()) + { + result = await _masterPasswordService.UpdateExistingMasterPasswordAndSaveAsync(user, new UpdateExistingPasswordData + { + MasterPasswordUnlock = model.UnlockData!.ToData(), + MasterPasswordAuthentication = model.AuthenticationData!.ToData(), + MasterPasswordHint = model.MasterPasswordHint, + }); + } + // To be removed in PM-33141 + else + { + result = await _userService.ChangePasswordAsync(user, model.MasterPasswordHash, + model.NewMasterPasswordHash, model.MasterPasswordHint, model.Key); + } + if (result.Succeeded) { return; @@ -670,17 +667,16 @@ public async Task PutUpdateTdePasswordAsync([FromBody] UpdateTdeOffboardingPassw } - var result; + IdentityResult result; if (model.RequestHasNewDataTypes()) { result = await _tdeOffboardingPasswordCommand.UpdateTdeOffboardingPasswordAsync(user, model.UnlockData!.ToData(), model.AuthenticationData!.ToData(), model.MasterPasswordHint); } else { - result = await _tdeOffboardingPasswordCommand.UpdateTdeOffboardingPasswordAsync(user, model.NewMasterPasswordHash, model.Key, model.MasterPasswordHint); + result = await _tdeOffboardingPasswordCommand.UpdateTdeOffboardingPasswordAsync(user, model.NewMasterPasswordHash!, model.Key!, model.MasterPasswordHint); } - var result = await _tdeOffboardingPasswordCommand.UpdateTdeOffboardingPasswordAsync(user, model.NewMasterPasswordHash, model.Key, model.MasterPasswordHint); if (result.Succeeded) { return; diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs index 26fbc9bb891a..6ef8aa8ae768 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs @@ -3,8 +3,10 @@ namespace Bit.Api.Auth.Models.Request.Accounts; -public class PasswordRequestModel : SecretVerificationRequestModel +public class PasswordRequestModel { + [Required] + public required string MasterPasswordHash { get; set; } [Obsolete("To be removed in PM-33141")] [StringLength(300)] public string? NewMasterPasswordHash { get; set; } @@ -17,13 +19,14 @@ public class PasswordRequestModel : SecretVerificationRequestModel public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } // To be removed in PM-33141 - public override IEnumerable Validate(ValidationContext validationContext) + public bool RequestHasNewDataTypes() { - foreach (var result in base.Validate(validationContext)) - { - yield return result; - } + return UnlockData is not null && AuthenticationData is not null; + } + // To be removed in PM-33141 + public IEnumerable Validate(ValidationContext validationContext) + { var hasNewPayloads = AuthenticationData is not null && UnlockData is not null; var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; diff --git a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs index e4db46456613..1bcc1af618e2 100644 --- a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs @@ -25,18 +25,14 @@ public class OrganizationUserResetPasswordRequestModel : IValidatableObject OrganizationUser = organizationUser, ResetMasterPassword = ResetMasterPassword, ResetTwoFactor = ResetTwoFactor, + // To be removed in PM-33141 NewMasterPasswordHash = NewMasterPasswordHash, + // To be removed in PM-33141 Key = Key, UnlockData = UnlockData, AuthenticationData = AuthenticationData, }; - // To be removed in PM-33141 - public bool RequestHasNewDataTypes() - { - return UnlockData is not null && AuthenticationData is not null; - } - // To be removed in PM-33141 public IEnumerable Validate(ValidationContext validationContext) { diff --git a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs index c397f5382b51..1aeda245df75 100644 --- a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs @@ -11,8 +11,8 @@ namespace Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; public interface ITdeOffboardingPasswordCommand { public Task UpdateTdeOffboardingPasswordAsync(User user, string masterPassword, string key, - string masterPasswordHint); + string? masterPasswordHint); public Task UpdateTdeOffboardingPasswordAsync(User user, MasterPasswordUnlockData unlockData, - MasterPasswordAuthenticationData authenticationData, string masterPasswordHint); + MasterPasswordAuthenticationData authenticationData, string? masterPasswordHint); } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs index 38f94afacf40..b47d758868c8 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs @@ -182,7 +182,6 @@ private async Task UpdateExistingPasswordHashAsync(User user, st return IdentityResult.Success; } - // Taken from the private async Task ValidatePasswordInternalAsync(User user, string password) { var errors = new List(); diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 0409babcd580..cd51350c6c69 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -28,6 +28,7 @@ public interface IUserService Task InitiateEmailChangeAsync(User user, string newEmail); Task ChangeEmailAsync(User user, string masterPassword, string newEmail, string newMasterPassword, string token, string key); + [Obsolete("To be removed in PM-33141")] Task ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint, string key); // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 [Obsolete("Use ISetKeyConnectorKeyCommand instead. This method will be removed in a future version.")] diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 1efd9b2543ce..262cc9266f14 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -481,6 +481,7 @@ public async Task ValidateClaimedUserDomainAsync(User user, stri }); } + [Obsolete("To be removed in PM-33141")] public async Task ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint, string key) { From c6331ba0e7bdc237a6744c5fa8a9bedfbe360c6d Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Thu, 9 Apr 2026 16:43:40 -0400 Subject: [PATCH 13/20] feat(master-password): Master Password Service - Added update temp password update --- .../OrganizationUsersController.cs | 20 +++++-- .../Auth/Controllers/AccountsController.cs | 25 ++++++++- .../Request/Accounts/PasswordRequestModel.cs | 2 +- ...ganizationUserResetPasswordRequestModel.cs | 10 +++- .../IAdminRecoverAccountCommand.cs | 4 +- .../v2/AdminRecoverAccountCommand.cs | 1 + .../v2/RecoverAccountRequest.cs | 26 +-------- .../ITdeOffboardingPasswordCommand.cs | 1 + .../TdeOffboardingPasswordCommand.cs | 22 ++++---- .../Interfaces/IUpdateTempPasswordCommand.cs | 14 +++++ .../TempPassword/UpdateTempPasswordCommand.cs | 53 +++++++++++++++++++ .../UserServiceCollectionExtensions.cs | 8 +++ src/Core/Services/IUserService.cs | 1 + .../Services/Implementations/UserService.cs | 1 + 14 files changed, 142 insertions(+), 46 deletions(-) create mode 100644 src/Core/Auth/UserFeatures/TempPassword/Interfaces/IUpdateTempPasswordCommand.cs create mode 100644 src/Core/Auth/UserFeatures/TempPassword/UpdateTempPasswordCommand.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 62b826aa71a5..0356bd23a944 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -41,6 +41,7 @@ using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using AccountRecoveryV2 = Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery.v2; using V1_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1.IRevokeOrganizationUserCommand; @@ -533,14 +534,25 @@ public async Task PutRecoverAccount(Guid orgId, Guid id, [FromBody] Org return Handle(await _adminRecoverAccountCommandV2.RecoverAccountAsync(commandRequest)); } - var result = await _adminRecoverAccountCommand.RecoverAccountAsync( - orgId, targetOrganizationUser, model.NewMasterPasswordHash!, model.Key!); - if (result.Succeeded) + IdentityResult identityResult; + if (model.RequestHasNewDataTypes()) + { + identityResult = await _adminRecoverAccountCommand.RecoverAccountAsync( + orgId, targetOrganizationUser, model.UnlockData!.ToData(), model.AuthenticationData!.ToData()); + } + // To be removed in PM-33141 + else + { + identityResult = await _adminRecoverAccountCommand.RecoverAccountAsync( + orgId, targetOrganizationUser, model.NewMasterPasswordHash!, model.Key!); + } + + if (identityResult.Succeeded) { return TypedResults.Ok(); } - foreach (var error in result.Errors) + foreach (var error in identityResult.Errors) { ModelState.AddModelError(string.Empty, error.Description); } diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index ebf2650648d8..05e58bf6c92b 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -13,6 +13,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.TempPassword.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; @@ -44,6 +45,7 @@ public class AccountsController( ISetInitialMasterPasswordCommandV1 setInitialMasterPasswordCommandV1, ITdeSetPasswordCommand tdeSetPasswordCommand, ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, + IUpdateTempPasswordCommand updateTempPasswordCommand, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IUserAccountKeysQuery userAccountKeysQuery, ITwoFactorEmailService twoFactorEmailService, @@ -60,6 +62,7 @@ public class AccountsController( private readonly IFinishSsoJitProvisionMasterPasswordCommand _finishSsoJitProvisionMasterPasswordCommand = finishSsoJitProvisionMasterPasswordCommand; private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand = tdeSetPasswordCommand; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand; + private readonly IUpdateTempPasswordCommand _updateTempPasswordCommand = updateTempPasswordCommand; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; private readonly IUserAccountKeysQuery _userAccountKeysQuery = userAccountKeysQuery; private readonly ITwoFactorEmailService _twoFactorEmailService = twoFactorEmailService; @@ -643,7 +646,25 @@ public async Task PutUpdateTempPasswordAsync([FromBody] UpdateTempPasswordReques throw new UnauthorizedAccessException(); } - var result = await _userService.UpdateTempPasswordAsync(user, model.NewMasterPasswordHash, model.Key, model.MasterPasswordHint); + IdentityResult result; + if (model.RequestHasNewDataTypes()) + { + result = await _updateTempPasswordCommand.UpdateTempPasswordAsync( + user, + model.UnlockData!.ToData(), + model.AuthenticationData!.ToData(), + model.MasterPasswordHint); + } + // To be removed in PM-33141 + else + { + result = await _userService.UpdateTempPasswordAsync( + user, + model.NewMasterPasswordHash, + model.Key, + model.MasterPasswordHint); + } + if (result.Succeeded) { return; @@ -666,12 +687,12 @@ public async Task PutUpdateTdePasswordAsync([FromBody] UpdateTdeOffboardingPassw throw new UnauthorizedAccessException(); } - IdentityResult result; if (model.RequestHasNewDataTypes()) { result = await _tdeOffboardingPasswordCommand.UpdateTdeOffboardingPasswordAsync(user, model.UnlockData!.ToData(), model.AuthenticationData!.ToData(), model.MasterPasswordHint); } + // To be removed in PM-33141 else { result = await _tdeOffboardingPasswordCommand.UpdateTdeOffboardingPasswordAsync(user, model.NewMasterPasswordHash!, model.Key!, model.MasterPasswordHint); diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs index 6ef8aa8ae768..acef2d7a50a6 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs @@ -3,7 +3,7 @@ namespace Bit.Api.Auth.Models.Request.Accounts; -public class PasswordRequestModel +public class PasswordRequestModel : IValidatableObject { [Required] public required string MasterPasswordHash { get; set; } diff --git a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs index 1bcc1af618e2..f56fb4fd40ea 100644 --- a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs @@ -16,8 +16,8 @@ public class OrganizationUserResetPasswordRequestModel : IValidatableObject [Obsolete("To be removed in PM-33141")] public string? Key { get; set; } - public MasterPasswordUnlockDataRequestModel? UnlockData; - public MasterPasswordAuthenticationDataRequestModel? AuthenticationData; + public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } + public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } public RecoverAccountRequest ToCommandRequest(Guid orgId, OrganizationUser organizationUser) => new() { @@ -33,6 +33,12 @@ public class OrganizationUserResetPasswordRequestModel : IValidatableObject AuthenticationData = AuthenticationData, }; + // To be removed in PM-33141 + public bool RequestHasNewDataTypes() + { + return UnlockData is not null && AuthenticationData is not null; + } + // To be removed in PM-33141 public IEnumerable Validate(ValidationContext validationContext) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs index 7fcd933b0807..27b87bb227bf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs @@ -29,8 +29,8 @@ Task RecoverAccountAsync(Guid orgId, OrganizationUser organizati /// /// The organization the user belongs to. /// The organization user being recovered. - /// The user's new master password hash. - /// The user's new master-password-sealed user key. + /// The user's new master-password unlock data. + /// The user's new master-password authentication data. /// An IdentityResult indicating success or failure. /// When organization settings, policy, or user state is invalid. /// When the user does not exist. diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs index 192df378be4b..d578b7b90581 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs @@ -68,6 +68,7 @@ public async Task RecoverAccountAsync(RecoverAccountRequest reque return result; } } + // To be removed in PM-33141 else { var result = await HandlePayloadWithDeprecatedRawDataAsync(user, request); diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/RecoverAccountRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/RecoverAccountRequest.cs index 8e654d996cab..2035e66ae7cf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/RecoverAccountRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/RecoverAccountRequest.cs @@ -1,10 +1,9 @@ -using System.ComponentModel.DataAnnotations; -using Bit.Core.Entities; +using Bit.Core.Entities; using Bit.Core.KeyManagement.Models.Api.Request; namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery.v2; -public record RecoverAccountRequest : IValidatableObject +public record RecoverAccountRequest { public required Guid OrgId { get; init; } public required OrganizationUser OrganizationUser { get; init; } @@ -24,25 +23,4 @@ public bool RequestHasNewDataTypes() { return UnlockData is not null && AuthenticationData is not null; } - - // To be removed in PM-33141 - public IEnumerable Validate(ValidationContext validationContext) - { - var hasNewPayloads = UnlockData is not null && AuthenticationData is not null; - var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; - - if (hasNewPayloads && hasLegacyPayloads) - { - yield return new ValidationResult( - "Cannot provide both new payloads (UnlockData/AuthenticationData) and legacy payloads (NewMasterPasswordHash/Key).", - [nameof(UnlockData), nameof(AuthenticationData), nameof(NewMasterPasswordHash), nameof(Key)]); - } - - if (!hasNewPayloads && !hasLegacyPayloads) - { - yield return new ValidationResult( - "Must provide either new payloads (UnlockData/AuthenticationData) or legacy payloads (NewMasterPasswordHash/Key).", - [nameof(UnlockData), nameof(AuthenticationData), nameof(NewMasterPasswordHash), nameof(Key)]); - } - } } diff --git a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs index 1aeda245df75..babe62a4d76c 100644 --- a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs @@ -10,6 +10,7 @@ namespace Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; /// public interface ITdeOffboardingPasswordCommand { + [Obsolete("To be removed in PM-33141")] public Task UpdateTdeOffboardingPasswordAsync(User user, string masterPassword, string key, string? masterPasswordHint); diff --git a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs index 7470387055b0..9fe7ea63a6d1 100644 --- a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs @@ -46,7 +46,7 @@ public TdeOffboardingPasswordCommand( } [Obsolete("To be removed in PM-33141")] - public async Task UpdateTdeOffboardingPasswordAsync(User user, string newMasterPassword, string key, string hint) + public async Task UpdateTdeOffboardingPasswordAsync(User user, string newMasterPassword, string key, string? hint) { if (string.IsNullOrWhiteSpace(newMasterPassword)) { @@ -108,7 +108,7 @@ public async Task UpdateTdeOffboardingPasswordAsync( User user, MasterPasswordUnlockData unlockData, MasterPasswordAuthenticationData authenticationData, - string hint) + string? masterPasswordHint) { var orgUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(user.Id); orgUserDetails = orgUserDetails.Where(x => x.UseSso).ToList(); @@ -117,35 +117,35 @@ public async Task UpdateTdeOffboardingPasswordAsync( throw new BadRequestException("User is not part of any organization that has SSO enabled."); } - var orgSSOUsers = await Task.WhenAll(orgUserDetails.Select(async x => await _ssoUserRepository.GetByUserIdOrganizationIdAsync(x.OrganizationId, user.Id))); - if (orgSSOUsers.Length != 1) + var orgSsoUsers = await Task.WhenAll(orgUserDetails.Select(async x => await _ssoUserRepository.GetByUserIdOrganizationIdAsync(x.OrganizationId, user.Id))); + if (orgSsoUsers.Length != 1) { throw new BadRequestException("User is part of no or multiple SSO configurations."); } var orgUser = orgUserDetails.First(); - var orgSSOConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgUser.OrganizationId); - if (orgSSOConfig == null) + var orgSsoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgUser.OrganizationId); + if (orgSsoConfig == null) { throw new BadRequestException("Organization SSO configuration not found."); } - if (orgSSOConfig.GetData().MemberDecryptionType != Enums.MemberDecryptionType.MasterPassword) + if (orgSsoConfig.GetData().MemberDecryptionType != Enums.MemberDecryptionType.MasterPassword) { throw new BadRequestException("Organization SSO Member Decryption Type is not Master Password."); } // We only want to be setting an initial master password here, if they already have one, // we are in an error state. - var result = await _masterPasswordService.OnlyMutateUserSetInitialMasterPasswordAsync(user, new SetInitialPasswordData + var identityResult = await _masterPasswordService.OnlyMutateUserSetInitialMasterPasswordAsync(user, new SetInitialPasswordData { MasterPasswordUnlock = unlockData, MasterPasswordAuthentication = authenticationData, - MasterPasswordHint = hint + MasterPasswordHint = masterPasswordHint }); - if (!result.Succeeded) + if (!identityResult.Succeeded) { - return result; + return identityResult; } // Side effect of running TDE offboarding, we want to force reset diff --git a/src/Core/Auth/UserFeatures/TempPassword/Interfaces/IUpdateTempPasswordCommand.cs b/src/Core/Auth/UserFeatures/TempPassword/Interfaces/IUpdateTempPasswordCommand.cs new file mode 100644 index 000000000000..316416d8aca8 --- /dev/null +++ b/src/Core/Auth/UserFeatures/TempPassword/Interfaces/IUpdateTempPasswordCommand.cs @@ -0,0 +1,14 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.TempPassword.Interfaces; + +public interface IUpdateTempPasswordCommand +{ + Task UpdateTempPasswordAsync( + User user, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, + string? masterPasswordHint); +} diff --git a/src/Core/Auth/UserFeatures/TempPassword/UpdateTempPasswordCommand.cs b/src/Core/Auth/UserFeatures/TempPassword/UpdateTempPasswordCommand.cs new file mode 100644 index 000000000000..129985fd1321 --- /dev/null +++ b/src/Core/Auth/UserFeatures/TempPassword/UpdateTempPasswordCommand.cs @@ -0,0 +1,53 @@ +using Bit.Core.Auth.UserFeatures.TempPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.TempPassword; + +public class UpdateTempPasswordCommand( + IMasterPasswordService masterPasswordService, + IUserRepository userRepository, + IMailService mailService, + IEventService eventService, + IPushNotificationService pushService) : IUpdateTempPasswordCommand +{ + public async Task UpdateTempPasswordAsync( + User user, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, + string? masterPasswordHint) + { + if (!user.ForcePasswordReset) + { + throw new BadRequestException("User does not have a temporary password to update."); + } + + var result = await masterPasswordService.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, new UpdateExistingPasswordData + { + MasterPasswordUnlock = unlockData, + MasterPasswordAuthentication = authenticationData, + MasterPasswordHint = masterPasswordHint, + }); + if (!result.Succeeded) + { + return result; + } + + user.ForcePasswordReset = false; + + await userRepository.ReplaceAsync(user); + await mailService.SendUpdatedTempPasswordEmailAsync(user.Email, user.Name ?? string.Empty); + await eventService.LogUserEventAsync(user.Id, EventType.User_UpdatedTempPassword); + await pushService.PushLogOutAsync(user.Id); + + return IdentityResult.Success; + } +} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index a115f1c73657..cef0ff94e485 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -5,6 +5,8 @@ using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.TempPassword; +using Bit.Core.Auth.UserFeatures.TempPassword.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -28,6 +30,7 @@ public static void AddUserServices(this IServiceCollection services, IGlobalSett services.AddDeviceTrustCommands(); services.AddEmergencyAccessCommands(); services.AddUserPasswordCommands(); + services.AddUpdateTempPasswordCommands(); services.AddUserRegistrationCommands(); services.AddWebAuthnLoginCommands(); services.AddTdeOffboardingPasswordCommands(); @@ -63,6 +66,11 @@ private static void AddTdeOffboardingPasswordCommands(this IServiceCollection se services.AddScoped(); } + private static void AddUpdateTempPasswordCommands(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddUserRegistrationCommands(this IServiceCollection services) { services.AddScoped(); diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index cd51350c6c69..49a6227e646b 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -35,6 +35,7 @@ Task ChangeEmailAsync(User user, string masterPassword, string n Task SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier); Task ConvertToKeyConnectorAsync(User user, string keyConnectorKeyWrappedUserKey); Task AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key); + [Obsolete("To be removed in PM-33141")] Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 262cc9266f14..0b71278075a6 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -658,6 +658,7 @@ public async Task AdminResetPasswordAsync(OrganizationUserType c return IdentityResult.Success; } + [Obsolete("To be removed in PM-33141")] public async Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint) { if (!user.ForcePasswordReset) From 6f80b6dcd56e9e0d6f15efe2547182c68b0a97fd Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Fri, 10 Apr 2026 10:33:47 -0400 Subject: [PATCH 14/20] test(master-password): Master Password Service - Updated the change kdf and account recovery tests. --- .../AdminRecoverAccountCommandTests.cs | 91 +++--- .../v2/AdminRecoverAccountCommandTests.cs | 252 ++++++++++++++- .../EmergencyAccessServiceTests.cs | 305 +++++++++++++++++- .../TdeOffboardingPasswordCommandTests.cs | 2 + .../MasterPasswordServiceTests.cs | 243 ++++++++++++-- .../SetInitialMasterPasswordCommandV1Tests.cs | 1 + ...tInitialMasterPasswordStateCommandTests.cs | 24 -- .../UpdateMasterPasswordStateCommandTests.cs | 24 -- .../Kdf/ChangeKdfCommandTests.cs | 61 ++-- 9 files changed, 821 insertions(+), 182 deletions(-) delete mode 100644 test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommandTests.cs delete mode 100644 test/Core.Test/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommandTests.cs diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs index 9800855ace03..5461486f34e3 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; @@ -27,8 +28,8 @@ public class AdminRecoverAccountCommandTests [Theory] [BitAutoData] public async Task RecoverAccountAsync_UserHasMasterPassword_CallsUpdate( - string newMasterPassword, - string key, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, Organization organization, OrganizationUser organizationUser, User user, @@ -42,30 +43,26 @@ public async Task RecoverAccountAsync_UserHasMasterPassword_CallsUpdate( SetupValidUser(sutProvider, user, organizationUser, hasMasterPassword: true); // Act - var result = await sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key); + var result = await sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, unlockData, authenticationData); // Assert Assert.True(result.Succeeded); - sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .OnlyMutateUserUpdateExistingMasterPasswordAsync( - Arg.Is(u => u.ForcePasswordReset), - newMasterPassword, - key, - Arg.Is(k => - k.KdfType == user.Kdf && - k.Iterations == user.KdfIterations && - k.Memory == user.KdfMemory && - k.Parallelism == user.KdfParallelism)); - sutProvider.GetDependency().DidNotReceive() - .OnlyMutateUserSetInitialMasterPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Is(d => + d.MasterPasswordUnlock == unlockData && + d.MasterPasswordAuthentication == authenticationData)); + await sutProvider.GetDependency().DidNotReceive() + .OnlyMutateUserSetInitialMasterPasswordAsync(Arg.Any(), Arg.Any()); await AssertCommonSuccessSideEffectsAsync(sutProvider, user, organization, organizationUser); } [Theory] [BitAutoData] public async Task RecoverAccountAsync_UserHasNoMasterPassword_CallsSetInitial( - string newMasterPassword, - string key, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, Organization organization, OrganizationUser organizationUser, User user, @@ -79,22 +76,18 @@ public async Task RecoverAccountAsync_UserHasNoMasterPassword_CallsSetInitial( SetupValidUser(sutProvider, user, organizationUser, hasMasterPassword: false); // Act - var result = await sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key); + var result = await sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, unlockData, authenticationData); // Assert Assert.True(result.Succeeded); - sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .OnlyMutateUserSetInitialMasterPasswordAsync( - Arg.Is(u => u.ForcePasswordReset), - newMasterPassword, - key, - Arg.Is(k => - k.KdfType == user.Kdf && - k.Iterations == user.KdfIterations && - k.Memory == user.KdfMemory && - k.Parallelism == user.KdfParallelism)); - sutProvider.GetDependency().DidNotReceive() - .OnlyMutateUserUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Is(d => + d.MasterPasswordUnlock == unlockData && + d.MasterPasswordAuthentication == authenticationData)); + await sutProvider.GetDependency().DidNotReceive() + .OnlyMutateUserUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any()); await AssertCommonSuccessSideEffectsAsync(sutProvider, user, organization, organizationUser); } @@ -102,8 +95,8 @@ public async Task RecoverAccountAsync_UserHasNoMasterPassword_CallsSetInitial( [BitAutoData] public async Task RecoverAccountAsync_OrganizationDoesNotExist_ThrowsBadRequest( [OrganizationUser] OrganizationUser organizationUser, - string newMasterPassword, - string key, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, SutProvider sutProvider) { // Arrange @@ -114,15 +107,15 @@ public async Task RecoverAccountAsync_OrganizationDoesNotExist_ThrowsBadRequest( // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.RecoverAccountAsync(orgId, organizationUser, newMasterPassword, key)); + sutProvider.Sut.RecoverAccountAsync(orgId, organizationUser, unlockData, authenticationData)); Assert.Equal("Organization does not allow password reset.", exception.Message); } [Theory] [BitAutoData] public async Task RecoverAccountAsync_OrganizationDoesNotAllowResetPassword_ThrowsBadRequest( - string newMasterPassword, - string key, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, Organization organization, [OrganizationUser] OrganizationUser organizationUser, SutProvider sutProvider) @@ -135,15 +128,15 @@ public async Task RecoverAccountAsync_OrganizationDoesNotAllowResetPassword_Thro // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key)); + sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, unlockData, authenticationData)); Assert.Equal("Organization does not allow password reset.", exception.Message); } [Theory] [BitAutoData] public async Task RecoverAccountAsync_InvalidPolicy_ThrowsBadRequest( - string newMasterPassword, - string key, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, Organization organization, [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) @@ -155,7 +148,7 @@ public async Task RecoverAccountAsync_InvalidPolicy_ThrowsBadRequest( // Act & Assert var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RecoverAccountAsync(organization.Id, new OrganizationUser { Id = Guid.NewGuid() }, - newMasterPassword, key)); + unlockData, authenticationData)); Assert.Equal("Organization does not have the password reset policy enabled.", exception.Message); } @@ -223,8 +216,8 @@ public static IEnumerable InvalidOrganizationUsers() public async Task RecoverAccountAsync_OrganizationUserIsInvalid_ThrowsBadRequest( OrganizationUser organizationUser, Organization organization, - string newMasterPassword, - string key, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, SutProvider sutProvider) { @@ -234,15 +227,15 @@ public async Task RecoverAccountAsync_OrganizationUserIsInvalid_ThrowsBadRequest // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key)); + sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, unlockData, authenticationData)); Assert.Equal("Organization User not valid", exception.Message); } [Theory] [BitAutoData] public async Task RecoverAccountAsync_UserDoesNotExist_ThrowsNotFoundException( - string newMasterPassword, - string key, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, Organization organization, OrganizationUser organizationUser, [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, @@ -258,14 +251,14 @@ public async Task RecoverAccountAsync_UserDoesNotExist_ThrowsNotFoundException( // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key)); + sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, unlockData, authenticationData)); } [Theory] [BitAutoData] public async Task RecoverAccountAsync_UserUsesKeyConnector_ThrowsBadRequest( - string newMasterPassword, - string key, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, Organization organization, OrganizationUser organizationUser, User user, @@ -283,7 +276,7 @@ public async Task RecoverAccountAsync_UserUsesKeyConnector_ThrowsBadRequest( // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key)); + sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, unlockData, authenticationData)); Assert.Equal("Cannot reset password of a user with Key Connector.", exception.Message); } @@ -320,6 +313,12 @@ private static void SetupValidUser(SutProvider sutPr sutProvider.GetDependency() .GetUserByIdAsync(user.Id) .Returns(user); + sutProvider.GetDependency() + .OnlyMutateUserUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any()) + .Returns(Microsoft.AspNetCore.Identity.IdentityResult.Success); + sutProvider.GetDependency() + .OnlyMutateUserSetInitialMasterPasswordAsync(Arg.Any(), Arg.Any()) + .Returns(Microsoft.AspNetCore.Identity.IdentityResult.Success); } private static async Task AssertCommonSuccessSideEffectsAsync(SutProvider sutProvider, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommandTests.cs index c0052a949b59..6a9651c29bb1 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommandTests.cs @@ -4,8 +4,11 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Auth.UserFeatures.TwoFactorAuth; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -20,9 +23,10 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.AccountRecovery.v2; [SutProviderCustomize] public class AdminRecoverAccountCommandTests { + [Obsolete("To be removed in PM-33141")] [Theory] [BitAutoData] - public async Task RecoverAccountAsync_ResetMasterPasswordOnly_Success( + public async Task Legacy_RecoverAccountAsync_ResetMasterPasswordOnly_Success( string newMasterPassword, string key, Organization organization, @@ -78,7 +82,62 @@ await sutProvider.GetDependency().Received(1) [Theory] [BitAutoData] - public async Task RecoverAccountAsync_ResetTwoFactorOnly_Success( + public async Task RecoverAccountAsync_ResetMasterPasswordOnly_Success( + MasterPasswordUnlockDataRequestModel unlockData, + MasterPasswordAuthenticationDataRequestModel authenticationData, + Organization organization, + OrganizationUser organizationUser, + User user, + SutProvider sutProvider) + { + // Arrange + SetupOrganization(sutProvider, organization); + SetupUser(sutProvider, user, organizationUser); + SetupSuccessfulMasterPasswordServiceUpdate(sutProvider, user); + SetupPolicy(sutProvider, user); + + var request = CreateNewRequest(organization.Id, organizationUser, + resetMasterPassword: true, resetTwoFactor: false, + unlockData: unlockData, authenticationData: authenticationData); + SetupValidValidator(sutProvider); + + // Act + var result = await sutProvider.Sut.RecoverAccountAsync(request); + + // Assert + Assert.True(result.IsSuccess); + + await sutProvider.GetDependency().Received(1) + .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( + user, Arg.Any()); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(user); + + Assert.True(user.ForcePasswordReset); + + await sutProvider.GetDependency().Received(1).SendAdminResetPasswordEmailAsync( + Arg.Is(user.Email), + Arg.Is(user.Name), + Arg.Is(organization.DisplayName()), + Arg.Is(true), + Arg.Is(false)); + + await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync( + Arg.Is(organizationUser), + Arg.Is(EventType.OrganizationUser_AdminResetPassword)); + + await sutProvider.GetDependency().DidNotReceive().LogOrganizationUserEventAsync( + Arg.Any(), + Arg.Is(EventType.OrganizationUser_AdminResetTwoFactor)); + + await sutProvider.GetDependency().Received(1) + .PushLogOutAsync(user.Id); + } + + [Obsolete("To be removed in PM-33141")] + [Theory] + [BitAutoData] + public async Task Legacy_RecoverAccountAsync_ResetTwoFactorOnly_Success( Organization organization, OrganizationUser organizationUser, User user, @@ -126,7 +185,57 @@ await sutProvider.GetDependency().Received(1) [Theory] [BitAutoData] - public async Task RecoverAccountAsync_ResetBoth_Success( + public async Task RecoverAccountAsync_ResetTwoFactorOnly_Success( + Organization organization, + OrganizationUser organizationUser, + User user, + SutProvider sutProvider) + { + // Arrange + SetupOrganization(sutProvider, organization); + SetupUser(sutProvider, user, organizationUser); + SetupPolicy(sutProvider, user); + + var request = CreateNewRequest(organization.Id, organizationUser, + resetMasterPassword: false, resetTwoFactor: true); + SetupValidValidator(sutProvider); + + // Act + var result = await sutProvider.Sut.RecoverAccountAsync(request); + + // Assert + Assert.True(result.IsSuccess); + + await sutProvider.GetDependency().Received(1) + .ResetAsync(user); + + await sutProvider.GetDependency().DidNotReceive() + .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( + Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency().Received(1).SendAdminResetPasswordEmailAsync( + Arg.Is(user.Email), + Arg.Is(user.Name), + Arg.Is(organization.DisplayName()), + Arg.Is(false), + Arg.Is(true)); + + await sutProvider.GetDependency().DidNotReceive().LogOrganizationUserEventAsync( + Arg.Any(), + Arg.Is(EventType.OrganizationUser_AdminResetPassword)); + + await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync( + Arg.Is(organizationUser), + Arg.Is(EventType.OrganizationUser_AdminResetTwoFactor)); + + await sutProvider.GetDependency().Received(1) + .PushLogOutAsync(user.Id); + } + + [Obsolete("To be removed in PM-33141")] + [Theory] + [BitAutoData] + public async Task Legacy_RecoverAccountAsync_ResetBoth_Success( string newMasterPassword, string key, Organization organization, @@ -185,7 +294,65 @@ await sutProvider.GetDependency().Received(1) [Theory] [BitAutoData] - public async Task RecoverAccountAsync_UpdatePasswordHashFails_ReturnsError( + public async Task RecoverAccountAsync_ResetBoth_Success( + MasterPasswordUnlockDataRequestModel unlockData, + MasterPasswordAuthenticationDataRequestModel authenticationData, + Organization organization, + OrganizationUser organizationUser, + User user, + SutProvider sutProvider) + { + // Arrange + SetupOrganization(sutProvider, organization); + SetupUser(sutProvider, user, organizationUser); + SetupSuccessfulMasterPasswordServiceUpdate(sutProvider, user); + SetupPolicy(sutProvider, user); + + var request = CreateNewRequest(organization.Id, organizationUser, + resetMasterPassword: true, resetTwoFactor: true, + unlockData: unlockData, authenticationData: authenticationData); + SetupValidValidator(sutProvider); + + // Act + var result = await sutProvider.Sut.RecoverAccountAsync(request); + + // Assert + Assert.True(result.IsSuccess); + + await sutProvider.GetDependency().Received(1) + .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( + user, Arg.Any()); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(user); + + Assert.True(user.ForcePasswordReset); + + await sutProvider.GetDependency().Received(1) + .ResetAsync(user); + + await sutProvider.GetDependency().Received(1).SendAdminResetPasswordEmailAsync( + Arg.Is(user.Email), + Arg.Is(user.Name), + Arg.Is(organization.DisplayName()), + Arg.Is(true), + Arg.Is(true)); + + await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync( + Arg.Is(organizationUser), + Arg.Is(EventType.OrganizationUser_AdminResetPassword)); + + await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync( + Arg.Is(organizationUser), + Arg.Is(EventType.OrganizationUser_AdminResetTwoFactor)); + + await sutProvider.GetDependency().Received(1) + .PushLogOutAsync(user.Id); + } + + [Obsolete("To be removed in PM-33141")] + [Theory] + [BitAutoData] + public async Task Legacy_RecoverAccountAsync_UpdatePasswordHashFails_ReturnsError( string newMasterPassword, string key, Organization organization, @@ -231,6 +398,55 @@ await sutProvider.GetDependency().DidNotReceive() .PushLogOutAsync(Arg.Any()); } + [Theory] + [BitAutoData] + public async Task RecoverAccountAsync_MasterPasswordServiceFails_ReturnsError( + MasterPasswordUnlockDataRequestModel unlockData, + MasterPasswordAuthenticationDataRequestModel authenticationData, + Organization organization, + OrganizationUser organizationUser, + User user, + SutProvider sutProvider) + { + // Arrange + SetupOrganization(sutProvider, organization); + SetupUser(sutProvider, user, organizationUser); + SetupPolicy(sutProvider, user); + + var failedResult = IdentityResult.Failed(new IdentityError { Description = "Password update failed" }); + sutProvider.GetDependency() + .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( + user, Arg.Any()) + .Returns(failedResult); + + var request = CreateNewRequest(organization.Id, organizationUser, + resetMasterPassword: true, resetTwoFactor: false, + unlockData: unlockData, authenticationData: authenticationData); + SetupValidValidator(sutProvider); + + // Act + var result = await sutProvider.Sut.RecoverAccountAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + Assert.Contains("Password update failed", result.AsError.Message); + + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAsync(Arg.Any()); + + await sutProvider.GetDependency().DidNotReceive() + .SendAdminResetPasswordEmailAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency().DidNotReceive() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency().DidNotReceive() + .PushLogOutAsync(Arg.Any()); + } + [Theory] [BitAutoData] public async Task RecoverAccountAsync_ValidationFails_ReturnsError( @@ -277,6 +493,25 @@ private static RecoverAccountRequest CreateRequest( }; } + private static RecoverAccountRequest CreateNewRequest( + Guid orgId, + OrganizationUser organizationUser, + bool resetMasterPassword, + bool resetTwoFactor, + MasterPasswordUnlockDataRequestModel? unlockData = null, + MasterPasswordAuthenticationDataRequestModel? authenticationData = null) + { + return new RecoverAccountRequest + { + OrgId = orgId, + OrganizationUser = organizationUser, + ResetMasterPassword = resetMasterPassword, + ResetTwoFactor = resetTwoFactor, + UnlockData = unlockData, + AuthenticationData = authenticationData, + }; + } + private static void SetupValidValidator(SutProvider sutProvider) { sutProvider.GetDependency() @@ -299,6 +534,7 @@ private static void SetupUser(SutProvider sutProvide .Returns(user); } + [Obsolete("To be removed in PM-33141")] private static void SetupSuccessfulPasswordUpdate(SutProvider sutProvider, User user, string newMasterPassword) { sutProvider.GetDependency() @@ -306,6 +542,14 @@ private static void SetupSuccessfulPasswordUpdate(SutProvider sutProvider, User user) + { + sutProvider.GetDependency() + .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( + user, Arg.Any()) + .Returns(IdentityResult.Success); + } + private static void SetupPolicy(SutProvider sutProvider, User user) { // 2FA policy does not apply diff --git a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs index 288930740838..455bc2d2aff4 100644 --- a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs @@ -8,9 +8,12 @@ using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.UserFeatures.EmergencyAccess; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tokens; @@ -1413,8 +1416,9 @@ public async Task TakeoverAsync_Success_ReturnsEmergencyAccessAndGrantorUser( Assert.Equal(result.Item2, grantor); } + [Obsolete("To be removed in PM-33141")] [Theory, BitAutoData] - public async Task PasswordAsync_RequestNotValid_EmergencyAccessIsNull_ThrowsBadRequest( + public async Task FinishRecoveryTakeoverAsync_Legacy_RequestNotValid_EmergencyAccessIsNull_ThrowsBadRequest( SutProvider sutProvider) { sutProvider.GetDependency() @@ -1422,13 +1426,14 @@ public async Task PasswordAsync_RequestNotValid_EmergencyAccessIsNull_ThrowsBadR .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.PasswordAsync(default, default, default, default)); + () => sutProvider.Sut.FinishRecoveryTakeoverAsync(default, default, (string)null, (string)null)); Assert.Contains("Emergency Access not valid.", exception.Message); } + [Obsolete("To be removed in PM-33141")] [Theory, BitAutoData] - public async Task PasswordAsync_RequestNotValid_GranteeNotEqualToRequestingUser_ThrowsBadRequest( + public async Task FinishRecoveryTakeoverAsync_Legacy_RequestNotValid_GranteeNotEqualToRequestingUser_ThrowsBadRequest( SutProvider sutProvider, Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) @@ -1440,17 +1445,18 @@ public async Task PasswordAsync_RequestNotValid_GranteeNotEqualToRequestingUser_ .Returns(emergencyAccess); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.PasswordAsync(emergencyAccess.Id, granteeUser, default, default)); + () => sutProvider.Sut.FinishRecoveryTakeoverAsync(emergencyAccess.Id, granteeUser, (string)null, (string)null)); Assert.Contains("Emergency Access not valid.", exception.Message); } + [Obsolete("To be removed in PM-33141")] [Theory] [BitAutoData(EmergencyAccessStatusType.Invited)] [BitAutoData(EmergencyAccessStatusType.Accepted)] [BitAutoData(EmergencyAccessStatusType.Confirmed)] [BitAutoData(EmergencyAccessStatusType.RecoveryInitiated)] - public async Task PasswordAsync_RequestNotValid_StatusType_ThrowsBadRequest( + public async Task FinishRecoveryTakeoverAsync_Legacy_RequestNotValid_StatusType_ThrowsBadRequest( EmergencyAccessStatusType statusType, SutProvider sutProvider, Core.Auth.Entities.EmergencyAccess emergencyAccess, @@ -1464,13 +1470,14 @@ public async Task PasswordAsync_RequestNotValid_StatusType_ThrowsBadRequest( .Returns(emergencyAccess); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.PasswordAsync(emergencyAccess.Id, granteeUser, default, default)); + () => sutProvider.Sut.FinishRecoveryTakeoverAsync(emergencyAccess.Id, granteeUser, (string)null, (string)null)); Assert.Contains("Emergency Access not valid.", exception.Message); } + [Obsolete("To be removed in PM-33141")] [Theory, BitAutoData] - public async Task PasswordAsync_RequestNotValid_TypeIsView_ThrowsBadRequest( + public async Task FinishRecoveryTakeoverAsync_Legacy_RequestNotValid_TypeIsView_ThrowsBadRequest( SutProvider sutProvider, Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) @@ -1483,13 +1490,14 @@ public async Task PasswordAsync_RequestNotValid_TypeIsView_ThrowsBadRequest( .Returns(emergencyAccess); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.PasswordAsync(emergencyAccess.Id, granteeUser, default, default)); + () => sutProvider.Sut.FinishRecoveryTakeoverAsync(emergencyAccess.Id, granteeUser, (string)null, (string)null)); Assert.Contains("Emergency Access not valid.", exception.Message); } + [Obsolete("To be removed in PM-33141")] [Theory, BitAutoData] - public async Task PasswordAsync_NonOrgUser_Success( + public async Task FinishRecoveryTakeoverAsync_Legacy_NonOrgUser_Success( SutProvider sutProvider, Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser, @@ -1509,7 +1517,7 @@ public async Task PasswordAsync_NonOrgUser_Success( .GetByIdAsync(emergencyAccess.GrantorId) .Returns(grantorUser); - await sutProvider.Sut.PasswordAsync(emergencyAccess.Id, granteeUser, passwordHash, key); + await sutProvider.Sut.FinishRecoveryTakeoverAsync(emergencyAccess.Id, granteeUser, passwordHash, key); await sutProvider.GetDependency() .Received(1) @@ -1519,11 +1527,12 @@ await sutProvider.GetDependency() .ReplaceAsync(Arg.Is(u => u.VerifyDevices == false && u.Key == key)); } + [Obsolete("To be removed in PM-33141")] [Theory] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.Custom)] - public async Task PasswordAsync_OrgUser_NotOrganizationOwner_RemovedFromOrganization_Success( + public async Task FinishRecoveryTakeoverAsync_Legacy_OrgUser_NotOrganizationOwner_RemovedFromOrganization_Success( OrganizationUserType userType, SutProvider sutProvider, Core.Auth.Entities.EmergencyAccess emergencyAccess, @@ -1551,7 +1560,7 @@ public async Task PasswordAsync_OrgUser_NotOrganizationOwner_RemovedFromOrganiza .GetManyByUserAsync(grantorUser.Id) .Returns([organizationUser]); - await sutProvider.Sut.PasswordAsync(emergencyAccess.Id, granteeUser, passwordHash, key); + await sutProvider.Sut.FinishRecoveryTakeoverAsync(emergencyAccess.Id, granteeUser, passwordHash, key); await sutProvider.GetDependency() .Received(1) @@ -1564,8 +1573,9 @@ await sutProvider.GetDependency() .RemoveUserAsync(organizationUser.OrganizationId, organizationUser.UserId.Value); } + [Obsolete("To be removed in PM-33141")] [Theory, BitAutoData] - public async Task PasswordAsync_OrgUser_IsOrganizationOwner_NotRemovedFromOrganization_Success( + public async Task FinishRecoveryTakeoverAsync_Legacy_OrgUser_IsOrganizationOwner_NotRemovedFromOrganization_Success( SutProvider sutProvider, Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser, @@ -1592,7 +1602,7 @@ public async Task PasswordAsync_OrgUser_IsOrganizationOwner_NotRemovedFromOrgani .GetManyByUserAsync(grantorUser.Id) .Returns([organizationUser]); - await sutProvider.Sut.PasswordAsync(emergencyAccess.Id, granteeUser, passwordHash, key); + await sutProvider.Sut.FinishRecoveryTakeoverAsync(emergencyAccess.Id, granteeUser, passwordHash, key); await sutProvider.GetDependency() .Received(1) @@ -1605,8 +1615,9 @@ await sutProvider.GetDependency() .RemoveUserAsync(organizationUser.OrganizationId, organizationUser.UserId.Value); } + [Obsolete("To be removed in PM-33141")] [Theory, BitAutoData] - public async Task PasswordAsync_Disables_NewDeviceVerification_And_TwoFactorProviders_On_The_Grantor( + public async Task FinishRecoveryTakeoverAsync_Legacy_Disables_NewDeviceVerification_And_TwoFactorProviders_On_The_Grantor( SutProvider sutProvider, User requestingUser, User grantor) { grantor.UsesKeyConnector = true; @@ -1633,7 +1644,269 @@ public async Task PasswordAsync_Disables_NewDeviceVerification_And_TwoFactorProv .GetByIdAsync(grantor.Id) .Returns(grantor); - await sutProvider.Sut.PasswordAsync(Guid.NewGuid(), requestingUser, "blablahash", "blablakey"); + await sutProvider.Sut.FinishRecoveryTakeoverAsync(Guid.NewGuid(), requestingUser, "blablahash", "blablakey"); + + Assert.Empty(grantor.GetTwoFactorProviders()); + Assert.False(grantor.VerifyDevices); + await sutProvider.GetDependency().Received().ReplaceAsync(grantor); + } + + [Theory, BitAutoData] + public async Task FinishRecoveryTakeoverAsync_RequestNotValid_EmergencyAccessIsNull_ThrowsBadRequest( + SutProvider sutProvider, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData) + { + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns((Core.Auth.Entities.EmergencyAccess)null); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.FinishRecoveryTakeoverAsync(default, default, unlockData, authenticationData)); + + Assert.Contains("Emergency Access not valid.", exception.Message); + } + + [Theory, BitAutoData] + public async Task FinishRecoveryTakeoverAsync_RequestNotValid_GranteeNotEqualToRequestingUser_ThrowsBadRequest( + SutProvider sutProvider, + Core.Auth.Entities.EmergencyAccess emergencyAccess, + User granteeUser, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData) + { + emergencyAccess.Status = EmergencyAccessStatusType.RecoveryApproved; + emergencyAccess.Type = EmergencyAccessType.Takeover; + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.FinishRecoveryTakeoverAsync(emergencyAccess.Id, granteeUser, unlockData, authenticationData)); + + Assert.Contains("Emergency Access not valid.", exception.Message); + } + + [Theory] + [BitAutoData(EmergencyAccessStatusType.Invited)] + [BitAutoData(EmergencyAccessStatusType.Accepted)] + [BitAutoData(EmergencyAccessStatusType.Confirmed)] + [BitAutoData(EmergencyAccessStatusType.RecoveryInitiated)] + public async Task FinishRecoveryTakeoverAsync_RequestNotValid_StatusType_ThrowsBadRequest( + EmergencyAccessStatusType statusType, + SutProvider sutProvider, + Core.Auth.Entities.EmergencyAccess emergencyAccess, + User granteeUser, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData) + { + emergencyAccess.GranteeId = granteeUser.Id; + emergencyAccess.Status = statusType; + emergencyAccess.Type = EmergencyAccessType.Takeover; + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.FinishRecoveryTakeoverAsync(emergencyAccess.Id, granteeUser, unlockData, authenticationData)); + + Assert.Contains("Emergency Access not valid.", exception.Message); + } + + [Theory, BitAutoData] + public async Task FinishRecoveryTakeoverAsync_RequestNotValid_TypeIsView_ThrowsBadRequest( + SutProvider sutProvider, + Core.Auth.Entities.EmergencyAccess emergencyAccess, + User granteeUser, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData) + { + emergencyAccess.GranteeId = granteeUser.Id; + emergencyAccess.Status = EmergencyAccessStatusType.RecoveryApproved; + emergencyAccess.Type = EmergencyAccessType.View; + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.FinishRecoveryTakeoverAsync(emergencyAccess.Id, granteeUser, unlockData, authenticationData)); + + Assert.Contains("Emergency Access not valid.", exception.Message); + } + + [Theory, BitAutoData] + public async Task FinishRecoveryTakeoverAsync_GrantorNotFound_ThrowsBadRequest( + SutProvider sutProvider, + Core.Auth.Entities.EmergencyAccess emergencyAccess, + User granteeUser, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData) + { + emergencyAccess.GranteeId = granteeUser.Id; + emergencyAccess.Status = EmergencyAccessStatusType.RecoveryApproved; + emergencyAccess.Type = EmergencyAccessType.Takeover; + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + sutProvider.GetDependency() + .GetByIdAsync(emergencyAccess.GrantorId) + .Returns((User)null); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.FinishRecoveryTakeoverAsync(emergencyAccess.Id, granteeUser, unlockData, authenticationData)); + + Assert.Contains("Grantor not found when trying to finish recovery takeover.", exception.Message); + } + + [Theory, BitAutoData] + public async Task FinishRecoveryTakeoverAsync_NonOrgUser_Success( + SutProvider sutProvider, + Core.Auth.Entities.EmergencyAccess emergencyAccess, + User granteeUser, + User grantorUser, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData) + { + emergencyAccess.GranteeId = granteeUser.Id; + emergencyAccess.GrantorId = grantorUser.Id; + emergencyAccess.Status = EmergencyAccessStatusType.RecoveryApproved; + emergencyAccess.Type = EmergencyAccessType.Takeover; + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + sutProvider.GetDependency() + .GetByIdAsync(emergencyAccess.GrantorId) + .Returns(grantorUser); + + await sutProvider.Sut.FinishRecoveryTakeoverAsync(emergencyAccess.Id, granteeUser, unlockData, authenticationData); + + await sutProvider.GetDependency() + .Received(1) + .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( + grantorUser, + Arg.Is(d => + d.MasterPasswordUnlock == unlockData && + d.MasterPasswordAuthentication == authenticationData)); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(u => u.VerifyDevices == false)); + } + + [Theory] + [BitAutoData(OrganizationUserType.User)] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Custom)] + public async Task FinishRecoveryTakeoverAsync_OrgUser_NotOrganizationOwner_RemovedFromOrganization_Success( + OrganizationUserType userType, + SutProvider sutProvider, + Core.Auth.Entities.EmergencyAccess emergencyAccess, + User granteeUser, + User grantorUser, + OrganizationUser organizationUser, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData) + { + emergencyAccess.GranteeId = granteeUser.Id; + emergencyAccess.GrantorId = grantorUser.Id; + emergencyAccess.Status = EmergencyAccessStatusType.RecoveryApproved; + emergencyAccess.Type = EmergencyAccessType.Takeover; + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + sutProvider.GetDependency() + .GetByIdAsync(emergencyAccess.GrantorId) + .Returns(grantorUser); + organizationUser.UserId = grantorUser.Id; + organizationUser.Type = userType; + sutProvider.GetDependency() + .GetManyByUserAsync(grantorUser.Id) + .Returns([organizationUser]); + + await sutProvider.Sut.FinishRecoveryTakeoverAsync(emergencyAccess.Id, granteeUser, unlockData, authenticationData); + + await sutProvider.GetDependency() + .Received(1) + .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword(grantorUser, Arg.Any()); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(u => u.VerifyDevices == false)); + await sutProvider.GetDependency() + .Received(1) + .RemoveUserAsync(organizationUser.OrganizationId, organizationUser.UserId.Value); + } + + [Theory, BitAutoData] + public async Task FinishRecoveryTakeoverAsync_OrgUser_IsOrganizationOwner_NotRemovedFromOrganization_Success( + SutProvider sutProvider, + Core.Auth.Entities.EmergencyAccess emergencyAccess, + User granteeUser, + User grantorUser, + OrganizationUser organizationUser, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData) + { + emergencyAccess.GranteeId = granteeUser.Id; + emergencyAccess.GrantorId = grantorUser.Id; + emergencyAccess.Status = EmergencyAccessStatusType.RecoveryApproved; + emergencyAccess.Type = EmergencyAccessType.Takeover; + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + sutProvider.GetDependency() + .GetByIdAsync(emergencyAccess.GrantorId) + .Returns(grantorUser); + organizationUser.UserId = grantorUser.Id; + organizationUser.Type = OrganizationUserType.Owner; + sutProvider.GetDependency() + .GetManyByUserAsync(grantorUser.Id) + .Returns([organizationUser]); + + await sutProvider.Sut.FinishRecoveryTakeoverAsync(emergencyAccess.Id, granteeUser, unlockData, authenticationData); + + await sutProvider.GetDependency() + .Received(1) + .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword(grantorUser, Arg.Any()); + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(u => u.VerifyDevices == false)); + await sutProvider.GetDependency() + .Received(0) + .RemoveUserAsync(organizationUser.OrganizationId, organizationUser.UserId.Value); + } + + [Theory, BitAutoData] + public async Task FinishRecoveryTakeoverAsync_Disables_NewDeviceVerification_And_TwoFactorProviders_On_The_Grantor( + SutProvider sutProvider, + User requestingUser, + User grantor, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData) + { + grantor.UsesKeyConnector = true; + grantor.SetTwoFactorProviders(new Dictionary + { + [TwoFactorProviderType.Email] = new TwoFactorProvider + { + MetaData = new Dictionary { ["Email"] = "asdfasf" }, + Enabled = true + } + }); + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess + { + GrantorId = grantor.Id, + GranteeId = requestingUser.Id, + Status = EmergencyAccessStatusType.RecoveryApproved, + Type = EmergencyAccessType.Takeover, + }; + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + sutProvider.GetDependency() + .GetByIdAsync(grantor.Id) + .Returns(grantor); + + await sutProvider.Sut.FinishRecoveryTakeoverAsync(Guid.NewGuid(), requestingUser, unlockData, authenticationData); Assert.Empty(grantor.GetTwoFactorProviders()); Assert.False(grantor.VerifyDevices); diff --git a/test/Core.Test/Auth/UserFeatures/TdeOffboarding/TdeOffboardingPasswordCommandTests.cs b/test/Core.Test/Auth/UserFeatures/TdeOffboarding/TdeOffboardingPasswordCommandTests.cs index 49558783f864..996887c6c769 100644 --- a/test/Core.Test/Auth/UserFeatures/TdeOffboarding/TdeOffboardingPasswordCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/TdeOffboarding/TdeOffboardingPasswordCommandTests.cs @@ -20,6 +20,7 @@ public class TdeOffboardingPasswordTests { [Theory] [BitAutoData] + [Obsolete("To be removed in PM-33141")] public async Task TdeOffboardingPasswordCommand_Success(SutProvider sutProvider, User user, string masterPassword, string key, string hint, OrganizationUserOrganizationDetails orgUserDetails, SsoUser ssoUser) { @@ -56,6 +57,7 @@ public async Task TdeOffboardingPasswordCommand_Success(SutProvider sutProvider, User user, string masterPassword, string key, string hint, OrganizationUserOrganizationDetails orgUserDetails, SsoUser ssoUser) { diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs index bb68afdfb374..8eece3ab31c7 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs @@ -1,9 +1,10 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword; -using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Identity; @@ -24,13 +25,31 @@ public async Task SetInitialMasterPassword_Success(User user, string masterPassw var sutProvider = CreateSutProvider(); user.MasterPassword = null; user.Key = null; + user.MasterPasswordSalt = null; + user.UsesKeyConnector = false; var expectedHash = "server-hashed-" + masterPasswordHash; sutProvider.GetDependency>() .HashPassword(user, masterPasswordHash) .Returns(expectedHash); + var setInitialData = new SetInitialPasswordData + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = masterPasswordHash, + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = key, + Salt = salt + } + }; + // Act - await sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf, salt); + await sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, setInitialData); // Assert Assert.Equal(expectedHash, user.MasterPassword); @@ -44,69 +63,139 @@ public async Task SetInitialMasterPassword_Success(User user, string masterPassw } [Theory, BitAutoData] - public async Task SetInitialMasterPassword_SaltNull_DoesNotSetMasterPasswordSalt(User user, string masterPasswordHash, string key, KdfSettings kdf) + public async Task SetInitialMasterPassword_SetsMasterPasswordHint(User user, string masterPasswordHash, string key, KdfSettings kdf, string salt, string hint) { // Arrange var sutProvider = CreateSutProvider(); user.MasterPassword = null; user.Key = null; - var originalSalt = user.MasterPasswordSalt; + user.MasterPasswordSalt = null; + user.UsesKeyConnector = false; + + var setInitialData = new SetInitialPasswordData + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = masterPasswordHash, + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = key, + Salt = salt + }, + MasterPasswordHint = hint + }; // Act - await sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf, null); + await sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, setInitialData); // Assert - Assert.Equal(originalSalt, user.MasterPasswordSalt); + Assert.Equal(hint, user.MasterPasswordHint); } [Theory, BitAutoData] - public async Task SetInitialMasterPassword_ThrowsWhenMasterPasswordAlreadySet(User user, string masterPasswordHash, string key, KdfSettings kdf) + public async Task SetInitialMasterPassword_ThrowsWhenMasterPasswordAlreadySet(User user, string masterPasswordHash, string key, KdfSettings kdf, string salt) { // Arrange var sutProvider = CreateSutProvider(); user.MasterPassword = "existing-hash"; user.Key = null; + user.MasterPasswordSalt = null; + + var setInitialData = new SetInitialPasswordData + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = masterPasswordHash, + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = key, + Salt = salt + } + }; // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf)); + sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, setInitialData)); Assert.Equal("User already has a master password set.", exception.Message); } [Theory, BitAutoData] - public async Task SetInitialMasterPassword_ThrowsWhenKeyAlreadySet(User user, string masterPasswordHash, string key, KdfSettings kdf) + public async Task SetInitialMasterPassword_ThrowsWhenKeyAlreadySet(User user, string masterPasswordHash, string key, KdfSettings kdf, string salt) { // Arrange var sutProvider = CreateSutProvider(); user.MasterPassword = null; user.Key = "existing-key"; + user.MasterPasswordSalt = null; + + var setInitialData = new SetInitialPasswordData + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = masterPasswordHash, + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = key, + Salt = salt + } + }; // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, masterPasswordHash, key, kdf)); - Assert.Equal("User already has a master password set.", exception.Message); + sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, setInitialData)); + Assert.Equal("User already has a key set.", exception.Message); } [Theory, BitAutoData] - public async Task SetInitialMasterPasswordAsync_CallsMutationThenCommand( + public async Task SetInitialMasterPasswordAsync_CallsMutationThenSavesUser( User user, string masterPasswordHash, string key, KdfSettings kdf, string salt) { // Arrange var sutProvider = CreateSutProvider(); user.MasterPassword = null; user.Key = null; + user.MasterPasswordSalt = null; + user.UsesKeyConnector = false; + + var setInitialData = new SetInitialPasswordData + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = masterPasswordHash, + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = key, + Salt = salt + } + }; // Act - await sutProvider.Sut.SetInitialMasterPasswordAndSaveUserAsync(user, masterPasswordHash, key, kdf, salt); + await sutProvider.Sut.SetInitialMasterPasswordAndSaveUserAsync(user, setInitialData); // Assert: mutation was applied Assert.NotNull(user.MasterPassword); Assert.Equal(key, user.Key); - // Assert: command was called with the mutated user - await sutProvider.GetDependency() + // Assert: user was persisted + await sutProvider.GetDependency() .Received(1) - .ExecuteAsync(user); + .ReplaceAsync(user); } // ------------------------------------------------------------------------- @@ -118,6 +207,9 @@ public async Task UpdateMasterPassword_Success(User user, string masterPasswordH { // Arrange var sutProvider = CreateSutProvider(); + user.MasterPassword = "existing-hash"; + user.MasterPasswordSalt = salt; + user.UsesKeyConnector = false; var kdf = new KdfSettings { KdfType = user.Kdf, @@ -125,19 +217,33 @@ public async Task UpdateMasterPassword_Success(User user, string masterPasswordH Memory = user.KdfMemory, Parallelism = user.KdfParallelism }; - user.MasterPassword = "existing-hash"; var expectedHash = "server-hashed-" + masterPasswordHash; sutProvider.GetDependency>() .HashPassword(user, masterPasswordHash) .Returns(expectedHash); + var updateData = new UpdateExistingPasswordData + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = masterPasswordHash, + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = key, + Salt = salt + } + }; + // Act - await sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, kdf, salt); + await sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, updateData); // Assert Assert.Equal(expectedHash, user.MasterPassword); Assert.Equal(key, user.Key); - Assert.Equal(salt, user.MasterPasswordSalt); Assert.NotNull(user.LastPasswordChangeDate); // KDF fields must be unchanged Assert.Equal(kdf.KdfType, user.Kdf); @@ -147,10 +253,13 @@ public async Task UpdateMasterPassword_Success(User user, string masterPasswordH } [Theory, BitAutoData] - public async Task UpdateMasterPassword_SaltNull_DoesNotSetMasterPasswordSalt(User user, string masterPasswordHash, string key) + public async Task UpdateMasterPassword_SetsMasterPasswordHint(User user, string masterPasswordHash, string key, string salt, string hint) { // Arrange var sutProvider = CreateSutProvider(); + user.MasterPassword = "existing-hash"; + user.MasterPasswordSalt = salt; + user.UsesKeyConnector = false; var kdf = new KdfSettings { KdfType = user.Kdf, @@ -158,35 +267,67 @@ public async Task UpdateMasterPassword_SaltNull_DoesNotSetMasterPasswordSalt(Use Memory = user.KdfMemory, Parallelism = user.KdfParallelism }; - user.MasterPassword = "existing-hash"; - var originalSalt = user.MasterPasswordSalt; + + var updateData = new UpdateExistingPasswordData + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = masterPasswordHash, + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = key, + Salt = salt + }, + MasterPasswordHint = hint + }; // Act - await sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, kdf, null); + await sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, updateData); // Assert - Assert.Equal(originalSalt, user.MasterPasswordSalt); + Assert.Equal(hint, user.MasterPasswordHint); } [Theory, BitAutoData] - public async Task UpdateMasterPassword_ThrowsWhenNoExistingPassword(User user, string masterPasswordHash, string key, KdfSettings kdf) + public async Task UpdateMasterPassword_ThrowsWhenNoExistingPassword(User user, string masterPasswordHash, string key, KdfSettings kdf, string salt) { // Arrange var sutProvider = CreateSutProvider(); user.MasterPassword = null; + var updateData = new UpdateExistingPasswordData + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = masterPasswordHash, + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = key, + Salt = salt + } + }; + // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, kdf)); + sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, updateData)); Assert.Equal("User does not have an existing master password to update.", exception.Message); } [Theory, BitAutoData] - public async Task UpdateMasterPassword_ThrowsWhenKdfMismatch(User user, string masterPasswordHash, string key) + public async Task UpdateMasterPassword_ThrowsWhenKdfMismatch(User user, string masterPasswordHash, string key, string salt) { // Arrange var sutProvider = CreateSutProvider(); user.MasterPassword = "existing-hash"; + user.UsesKeyConnector = false; user.Kdf = KdfType.PBKDF2_SHA256; user.KdfIterations = 600000; // Pass KDF settings that differ from user's stored KDF @@ -198,16 +339,35 @@ public async Task UpdateMasterPassword_ThrowsWhenKdfMismatch(User user, string m Parallelism = 4 }; + var updateData = new UpdateExistingPasswordData + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = mismatchedKdf, + MasterPasswordAuthenticationHash = masterPasswordHash, + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = mismatchedKdf, + MasterKeyWrappedUserKey = key, + Salt = salt + } + }; + // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, masterPasswordHash, key, mismatchedKdf)); + sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, updateData)); } [Theory, BitAutoData] - public async Task UpdateMasterPasswordAsync_CallsMutationThenCommand(User user, string masterPasswordHash, string key) + public async Task UpdateMasterPasswordAsync_CallsMutationThenSavesUser(User user, string masterPasswordHash, string key, string salt) { // Arrange var sutProvider = CreateSutProvider(); + user.MasterPassword = "existing-hash"; + user.MasterPasswordSalt = salt; + user.UsesKeyConnector = false; var kdf = new KdfSettings { KdfType = user.Kdf, @@ -215,18 +375,33 @@ public async Task UpdateMasterPasswordAsync_CallsMutationThenCommand(User user, Memory = user.KdfMemory, Parallelism = user.KdfParallelism }; - user.MasterPassword = "existing-hash"; + + var updateData = new UpdateExistingPasswordData + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = masterPasswordHash, + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = key, + Salt = salt + } + }; // Act - await sutProvider.Sut.UpdateMasterPasswordAndSaveAsync(user, masterPasswordHash, key, kdf); + await sutProvider.Sut.UpdateExistingMasterPasswordAndSaveAsync(user, updateData); // Assert: mutation was applied Assert.NotNull(user.MasterPassword); Assert.Equal(key, user.Key); - // Assert: command was called with the mutated user - await sutProvider.GetDependency() + // Assert: user was persisted + await sutProvider.GetDependency() .Received(1) - .ExecuteAsync(user); + .ReplaceAsync(user); } } diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1Tests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1Tests.cs index d87b2730260a..62ee0175808e 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1Tests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1Tests.cs @@ -16,6 +16,7 @@ namespace Bit.Core.Test.Auth.UserFeatures.UserMasterPassword; [SutProviderCustomize] +[Obsolete("To be removed in PM-33141")] public class SetInitialMasterPasswordCommandV1Tests { [Theory] diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommandTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommandTests.cs deleted file mode 100644 index cce03f1dd3d9..000000000000 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordStateCommandTests.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Bit.Core.Auth.UserFeatures.UserMasterPassword; -using Bit.Core.Entities; -using Bit.Core.Repositories; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Auth.UserFeatures.UserMasterPassword; - -[SutProviderCustomize] -public class SetInitialMasterPasswordStateCommandTests -{ - [Theory] - [BitAutoData] - public async Task ExecuteAsync_CallsReplaceAsync( - SutProvider sutProvider, - User user) - { - await sutProvider.Sut.ExecuteAsync(user); - - await sutProvider.GetDependency().Received(1).ReplaceAsync(user); - } -} diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommandTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommandTests.cs deleted file mode 100644 index 3a7f53835907..000000000000 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/UpdateMasterPasswordStateCommandTests.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Bit.Core.Auth.UserFeatures.UserMasterPassword; -using Bit.Core.Entities; -using Bit.Core.Repositories; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Auth.UserFeatures.UserMasterPassword; - -[SutProviderCustomize] -public class UpdateMasterPasswordStateCommandTests -{ - [Theory] - [BitAutoData] - public async Task ExecuteAsync_CallsReplaceAsync( - SutProvider sutProvider, - User user) - { - await sutProvider.Sut.ExecuteAsync(user); - - await sutProvider.GetDependency().Received(1).ReplaceAsync(user); - } -} diff --git a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs index 991935b92896..0ef3c22dad2d 100644 --- a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs +++ b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs @@ -1,12 +1,13 @@ #nullable enable +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Kdf.Implementations; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Platform.Push; -using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -25,7 +26,8 @@ public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider s { sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); - sutProvider.GetDependency().UpdatePasswordHash(Arg.Any(), Arg.Any()) + sutProvider.GetDependency() + .UpdateExistingMasterPasswordAndSaveAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Success)); var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; @@ -44,13 +46,11 @@ public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider s await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(u => - u.Id == user.Id - && u.Kdf == Enums.KdfType.Argon2id - && u.KdfIterations == 4 - && u.KdfMemory == 512 - && u.KdfParallelism == 4 - )); + await sutProvider.GetDependency().Received(1) + .UpdateExistingMasterPasswordAndSaveAsync(user, Arg.Is(d => + d.MasterPasswordAuthentication == authenticationData + && d.MasterPasswordUnlock == unlockData + )); } [Theory] @@ -129,23 +129,19 @@ public async Task }; sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + sutProvider.GetDependency() + .UpdateExistingMasterPasswordAndSaveAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Success)); sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(false); await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(u => - u.Id == user.Id - && u.Kdf == constantKdf.KdfType - && u.KdfIterations == constantKdf.Iterations - && u.KdfMemory == constantKdf.Memory - && u.KdfParallelism == constantKdf.Parallelism - && u.Key == "new-wrapped-key" - )); - await sutProvider.GetDependency().Received(1).UpdatePasswordHash(user, - authenticationData.MasterPasswordAuthenticationHash, validatePassword: true, refreshStamp: true); + await sutProvider.GetDependency().Received(1) + .UpdateExistingMasterPasswordAndSaveAsync(user, Arg.Is(d => + d.MasterPasswordAuthentication == authenticationData + && d.MasterPasswordUnlock == unlockData + && d.RefreshStamp == true + )); await sutProvider.GetDependency().Received(1).PushLogOutAsync(user.Id); sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange); } @@ -177,23 +173,19 @@ public async Task }; sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + sutProvider.GetDependency() + .UpdateExistingMasterPasswordAndSaveAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Success)); sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(u => - u.Id == user.Id - && u.Kdf == constantKdf.KdfType - && u.KdfIterations == constantKdf.Iterations - && u.KdfMemory == constantKdf.Memory - && u.KdfParallelism == constantKdf.Parallelism - && u.Key == "new-wrapped-key" - )); - await sutProvider.GetDependency().Received(1).UpdatePasswordHash(user, - authenticationData.MasterPasswordAuthenticationHash, validatePassword: true, refreshStamp: false); + await sutProvider.GetDependency().Received(1) + .UpdateExistingMasterPasswordAndSaveAsync(user, Arg.Is(d => + d.MasterPasswordAuthentication == authenticationData + && d.MasterPasswordUnlock == unlockData + && d.RefreshStamp == false + )); await sutProvider.GetDependency().Received(1) .PushLogOutAsync(user.Id, false, PushNotificationLogOutReason.KdfChange); await sutProvider.GetDependency().Received(1).PushSyncSettingsAsync(user.Id); @@ -286,7 +278,8 @@ public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvi sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); var failedResult = IdentityResult.Failed(new IdentityError { Code = "TestFail", Description = "Test fail" }); - sutProvider.GetDependency().UpdatePasswordHash(Arg.Any(), Arg.Any()) + sutProvider.GetDependency() + .UpdateExistingMasterPasswordAndSaveAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(failedResult)); var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; From d527c60d143bdd55dd8429e66f39085e0b3c7bd5 Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Fri, 10 Apr 2026 12:31:52 -0400 Subject: [PATCH 15/20] fix(master-password): Master Password Service - Updated update-temp-password to use it's own standalone request and added commment for other function. --- .../Auth/Controllers/AccountsController.cs | 10 +++++ .../UpdateTempPasswordRequestModel.cs | 40 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 05e58bf6c92b..ee53086fd653 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -173,6 +173,16 @@ public async Task PostVerifyEmailToken([FromBody] VerifyEmailRequestModel model) throw new BadRequestException(ModelState); } + /// + /// For a user updating an existing password. + /// + /// If calling this when a user does not have a master password, it will fail. + /// I don't think is a new boundary that has been introduced. + /// Need to double check this / get feedback from Jared. + /// + /// + /// + /// [HttpPost("password")] public async Task PostPassword([FromBody] PasswordRequestModel model) { diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs index a408df3d2243..7a76732c989a 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs @@ -1,10 +1,46 @@ using System.ComponentModel.DataAnnotations; -using Bit.Api.Models.Request.Organizations; +using Bit.Core.KeyManagement.Models.Api.Request; namespace Bit.Api.Auth.Models.Request.Accounts; -public class UpdateTempPasswordRequestModel : OrganizationUserResetPasswordRequestModel +public class UpdateTempPasswordRequestModel { + [Obsolete("To be removed in PM-33141")] + [StringLength(300)] + public string? NewMasterPasswordHash { get; set; } + [Obsolete("To be removed in PM-33141")] + public string? Key { get; set; } + + public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } + public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } + + // To be removed in PM-33141 + public bool RequestHasNewDataTypes() + { + return UnlockData is not null && AuthenticationData is not null; + } + + // To be removed in PM-33141 + public IEnumerable Validate(ValidationContext validationContext) + { + var hasNewPayloads = UnlockData is not null && AuthenticationData is not null; + var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; + + if (hasNewPayloads && hasLegacyPayloads) + { + yield return new ValidationResult( + "Cannot provide both new payloads (UnlockData/AuthenticationData) and legacy payloads (NewMasterPasswordHash/Key).", + [nameof(UnlockData), nameof(AuthenticationData), nameof(NewMasterPasswordHash), nameof(Key)]); + } + + if (!hasNewPayloads && !hasLegacyPayloads) + { + yield return new ValidationResult( + "Must provide either new payloads (UnlockData/AuthenticationData) or legacy payloads (NewMasterPasswordHash/Key).", + [nameof(UnlockData), nameof(AuthenticationData), nameof(NewMasterPasswordHash), nameof(Key)]); + } + } + [StringLength(50)] public string? MasterPasswordHint { get; set; } } From 6feb4cec534718c9318d05d6e5b0f64972328d3a Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Fri, 10 Apr 2026 13:22:01 -0400 Subject: [PATCH 16/20] fix(master-password): Master Password Service - Updated accounts controller to check password while changing. --- .../Auth/Controllers/AccountsController.cs | 20 +++++++--- .../Controllers/EmergencyAccessController.cs | 2 +- .../Request/Accounts/ChangeKdfRequestModel.cs | 39 +++++++++++++++++++ .../Repositories/UserRepository.cs | 1 + .../Repositories/UserRepository.cs | 1 + 5 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 src/Api/Auth/Models/Request/Accounts/ChangeKdfRequestModel.cs diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index ee53086fd653..941d4c2f09e8 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -195,12 +195,20 @@ public async Task PostPassword([FromBody] PasswordRequestModel model) IdentityResult result; if (model.RequestHasNewDataTypes()) { - result = await _masterPasswordService.UpdateExistingMasterPasswordAndSaveAsync(user, new UpdateExistingPasswordData + // Jared, I'm unsure if check password should be turned into a query as a part of this work. + if (await _userService.CheckPasswordAsync(user, model.AuthenticationData!.MasterPasswordAuthenticationHash)) { - MasterPasswordUnlock = model.UnlockData!.ToData(), - MasterPasswordAuthentication = model.AuthenticationData!.ToData(), - MasterPasswordHint = model.MasterPasswordHint, - }); + result = await _masterPasswordService.UpdateExistingMasterPasswordAndSaveAsync(user, new UpdateExistingPasswordData + { + MasterPasswordUnlock = model.UnlockData!.ToData(), + MasterPasswordAuthentication = model.AuthenticationData!.ToData(), + MasterPasswordHint = model.MasterPasswordHint + }); + } + else + { + throw new BadRequestException("Passwords do not match."); + } } // To be removed in PM-33141 else @@ -298,7 +306,7 @@ public async Task PostVerifyPassword([FromBod } [HttpPost("kdf")] - public async Task PostKdf([FromBody] PasswordRequestModel model) + public async Task PostKdf([FromBody] ChangeKdfRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index 4c6e719c34b4..599a001e036e 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -174,11 +174,11 @@ public async Task Password(Guid id, [FromBody] EmergencyAccessPasswordRequestMod { var user = await _userService.GetUserByPrincipalAsync(User); - // Unwind this with PM-33141 to only use the new payload if (model.RequestHasNewDataTypes()) { await _emergencyAccessService.FinishRecoveryTakeoverAsync(id, user, model.UnlockData!.ToData(), model.AuthenticationData!.ToData()); } + // To be removed in PM-33141 else { await _emergencyAccessService.FinishRecoveryTakeoverAsync(id, user, model.NewMasterPasswordHash!, model.Key!); diff --git a/src/Api/Auth/Models/Request/Accounts/ChangeKdfRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/ChangeKdfRequestModel.cs new file mode 100644 index 000000000000..69d0040acce4 --- /dev/null +++ b/src/Api/Auth/Models/Request/Accounts/ChangeKdfRequestModel.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Api.Request; + +namespace Bit.Api.Auth.Models.Request.Accounts; + +public class ChangeKdfRequestModel : IValidatableObject +{ + [Required] + public required string MasterPasswordHash { get; set; } + [Obsolete("To be removed in PM-33141")] + [StringLength(300)] + public string? NewMasterPasswordHash { get; set; } + [Obsolete("To be removed in PM-33141")] + public string? Key { get; set; } + + public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } + public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } + + // To be removed in PM-33141 + public IEnumerable Validate(ValidationContext validationContext) + { + var hasNewPayloads = AuthenticationData is not null && UnlockData is not null; + var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; + + if (hasNewPayloads && hasLegacyPayloads) + { + yield return new ValidationResult( + "Cannot provide both new payloads (UnlockData/AuthenticationData) and legacy payloads (NewMasterPasswordHash/Key).", + [nameof(AuthenticationData), nameof(UnlockData), nameof(NewMasterPasswordHash), nameof(Key)]); + } + + if (!hasNewPayloads && !hasLegacyPayloads) + { + yield return new ValidationResult( + "Must provide either new payloads (UnlockData/AuthenticationData) or legacy payloads (NewMasterPasswordHash/Key).", + [nameof(AuthenticationData), nameof(UnlockData), nameof(NewMasterPasswordHash), nameof(Key)]); + } + } +} diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index eaeaf5f1805a..6eb8a5261736 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -487,6 +487,7 @@ public UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData ma KdfParallelism = masterPasswordUnlockData.Kdf.Parallelism, RevisionDate = timestamp, AccountRevisionDate = timestamp, + // Need to add User.LastPasswordChangeDate here MasterPasswordSalt = masterPasswordUnlockData.Salt }, transaction: transaction, diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index b003d997bac1..17c24b4697f9 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -562,6 +562,7 @@ public UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData ma userEntity.KdfParallelism = masterPasswordUnlockData.Kdf.Parallelism; userEntity.RevisionDate = timestamp; userEntity.AccountRevisionDate = timestamp; + // userEntity.LastPasswordChangeDate = timestamp; This needs adding userEntity.MasterPasswordSalt = masterPasswordUnlockData.Salt; await dbContext.SaveChangesAsync(); }; From 4d942f35c5e5fbbd30288bb61be2d810f8eb5c67 Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Fri, 10 Apr 2026 14:00:18 -0400 Subject: [PATCH 17/20] fix(master-password): Master Password Service - Fixed master password service tests. --- src/Api/Auth/Controllers/AccountsController.cs | 4 ++-- .../Repositories/UserRepository.cs | 2 +- .../Repositories/UserRepository.cs | 2 +- .../MasterPasswordServiceTests.cs | 18 ++++++++++++------ 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 941d4c2f09e8..7652a36db160 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -266,8 +266,8 @@ public async Task PostSetPasswordAsync([FromBody] SetInitialPasswordRequestModel var result = await _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync( user, - model.MasterPasswordHash, - model.Key, + model.MasterPasswordHash!, + model.Key!, model.OrgIdentifier); if (result.Succeeded) diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index 6eb8a5261736..1ca83f8c0fa2 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -487,7 +487,7 @@ public UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData ma KdfParallelism = masterPasswordUnlockData.Kdf.Parallelism, RevisionDate = timestamp, AccountRevisionDate = timestamp, - // Need to add User.LastPasswordChangeDate here + // Need to add User.LastPasswordChangeDate here in PM-34905 MasterPasswordSalt = masterPasswordUnlockData.Salt }, transaction: transaction, diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index 17c24b4697f9..028165f79e42 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -562,7 +562,7 @@ public UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData ma userEntity.KdfParallelism = masterPasswordUnlockData.Kdf.Parallelism; userEntity.RevisionDate = timestamp; userEntity.AccountRevisionDate = timestamp; - // userEntity.LastPasswordChangeDate = timestamp; This needs adding + // userEntity.LastPasswordChangeDate = timestamp; This needs adding in PM-34905 userEntity.MasterPasswordSalt = masterPasswordUnlockData.Salt; await dbContext.SaveChangesAsync(); }; diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs index 8eece3ab31c7..6478de1fe05c 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs @@ -45,7 +45,8 @@ public async Task SetInitialMasterPassword_Success(User user, string masterPassw Kdf = kdf, MasterKeyWrappedUserKey = key, Salt = salt - } + }, + ValidatePassword = false }; // Act @@ -86,7 +87,8 @@ public async Task SetInitialMasterPassword_SetsMasterPasswordHint(User user, str MasterKeyWrappedUserKey = key, Salt = salt }, - MasterPasswordHint = hint + MasterPasswordHint = hint, + ValidatePassword = false }; // Act @@ -182,7 +184,8 @@ public async Task SetInitialMasterPasswordAsync_CallsMutationThenSavesUser( Kdf = kdf, MasterKeyWrappedUserKey = key, Salt = salt - } + }, + ValidatePassword = false }; // Act @@ -235,7 +238,8 @@ public async Task UpdateMasterPassword_Success(User user, string masterPasswordH Kdf = kdf, MasterKeyWrappedUserKey = key, Salt = salt - } + }, + ValidatePassword = false }; // Act @@ -282,7 +286,8 @@ public async Task UpdateMasterPassword_SetsMasterPasswordHint(User user, string MasterKeyWrappedUserKey = key, Salt = salt }, - MasterPasswordHint = hint + MasterPasswordHint = hint, + ValidatePassword = false }; // Act @@ -389,7 +394,8 @@ public async Task UpdateMasterPasswordAsync_CallsMutationThenSavesUser(User user Kdf = kdf, MasterKeyWrappedUserKey = key, Salt = salt - } + }, + ValidatePassword = false }; // Act From d2b93c06a15c1078145557a3a58c18a1738a380f Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Mon, 13 Apr 2026 11:53:30 -0400 Subject: [PATCH 18/20] feat(master-password): Master Password Service - Did some renames and working on adding in the self service password change command. --- .../Auth/Controllers/AccountsController.cs | 7 ++-- .../UpdateTempPasswordRequestModel.cs | 5 +-- .../AdminRecoverAccountCommand.cs | 4 +- .../v2/AdminRecoverAccountCommand.cs | 4 +- .../EmergencyAccess/EmergencyAccessService.cs | 4 +- .../TdeOffboardingPasswordCommand.cs | 2 +- .../TempPassword/UpdateTempPasswordCommand.cs | 2 +- ...SetInitialOrUpdateExistingPasswordData.cs} | 2 +- ...ishSsoJitProvisionMasterPasswordCommand.cs | 2 +- .../Interfaces/IMasterPasswordService.cs | 41 +++++++++++-------- .../ISelfServicePasswordChangeCommand.cs | 6 +++ .../MasterPasswordService.cs | 22 +++++----- .../SelfServicePasswordChangeCommand.cs | 8 ++++ .../TdeSetPasswordCommand.cs | 2 +- .../Kdf/Implementations/ChangeKdfCommand.cs | 2 +- src/Core/Services/IUserService.cs | 1 + .../AdminRecoverAccountCommandTests.cs | 12 +++--- .../v2/AdminRecoverAccountCommandTests.cs | 20 ++++----- .../EmergencyAccessServiceTests.cs | 8 ++-- .../MasterPasswordServiceTests.cs | 20 ++++----- .../Kdf/ChangeKdfCommandTests.cs | 14 +++---- 21 files changed, 104 insertions(+), 84 deletions(-) rename src/Core/Auth/UserFeatures/UserMasterPassword/Data/{SetInitialOrChangeExistingPasswordData.cs => SetInitialOrUpdateExistingPasswordData.cs} (97%) create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISelfServicePasswordChangeCommand.cs create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/SelfServicePasswordChangeCommand.cs diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 7652a36db160..349163a706ac 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -177,8 +177,6 @@ public async Task PostVerifyEmailToken([FromBody] VerifyEmailRequestModel model) /// For a user updating an existing password. /// /// If calling this when a user does not have a master password, it will fail. - /// I don't think is a new boundary that has been introduced. - /// Need to double check this / get feedback from Jared. /// /// /// @@ -195,10 +193,11 @@ public async Task PostPassword([FromBody] PasswordRequestModel model) IdentityResult result; if (model.RequestHasNewDataTypes()) { - // Jared, I'm unsure if check password should be turned into a query as a part of this work. + // Make a self service password change command + if (await _userService.CheckPasswordAsync(user, model.AuthenticationData!.MasterPasswordAuthenticationHash)) { - result = await _masterPasswordService.UpdateExistingMasterPasswordAndSaveAsync(user, new UpdateExistingPasswordData + result = await _masterPasswordService.SaveUpdateExistingMasterPasswordAsync(user, new UpdateExistingPasswordData { MasterPasswordUnlock = model.UnlockData!.ToData(), MasterPasswordAuthentication = model.AuthenticationData!.ToData(), diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs index 7a76732c989a..396ebcce630f 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs @@ -10,6 +10,8 @@ public class UpdateTempPasswordRequestModel public string? NewMasterPasswordHash { get; set; } [Obsolete("To be removed in PM-33141")] public string? Key { get; set; } + [StringLength(50)] + public string? MasterPasswordHint { get; set; } public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } @@ -40,7 +42,4 @@ public IEnumerable Validate(ValidationContext validationContex [nameof(UnlockData), nameof(AuthenticationData), nameof(NewMasterPasswordHash), nameof(Key)]); } } - - [StringLength(50)] - public string? MasterPasswordHint { get; set; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs index 1488f2830d49..80704f4619ba 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs @@ -129,7 +129,7 @@ public async Task RecoverAccountAsync( // those who do not. TDE users can be recovered and will not have a password if (user.HasMasterPassword()) { - mutationResult = await masterPasswordService.OnlyMutateUserUpdateExistingMasterPasswordAsync( + mutationResult = await masterPasswordService.MutateUserUpdateExistingMasterPasswordAsync( user, new UpdateExistingPasswordData { @@ -139,7 +139,7 @@ public async Task RecoverAccountAsync( } else { - mutationResult = await masterPasswordService.OnlyMutateUserSetInitialMasterPasswordAsync( + mutationResult = await masterPasswordService.MutateSetInitialMasterPasswordAsync( user, new SetInitialPasswordData { diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs index 9cfb2a9df01f..66e319b75953 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs @@ -144,9 +144,9 @@ private async Task HandlePayloadsWithUnlockAndAuthenticationDataA // We can recover an account for users who both have a master password and // those who do not. TDE users can be account recovered which will not have // an initial master password set. - var identityResultFromMutation = await masterPasswordService.OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( + var identityResultFromMutation = await masterPasswordService.MutateSetInitialPasswordOrUpdateExistingPassword( user, - new SetInitialOrChangeExistingPasswordData + new SetInitialOrUpdateExistingPasswordData { MasterPasswordUnlock = request.UnlockData!.ToData(), MasterPasswordAuthentication = request.AuthenticationData!.ToData(), diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs index d0c1fdafed2f..1c5740d0b1b0 100644 --- a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs @@ -416,9 +416,9 @@ public async Task FinishRecoveryTakeoverAsync( throw new BadRequestException("Grantor not found when trying to finish recovery takeover."); } - await _masterPasswordService.OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( + await _masterPasswordService.MutateSetInitialPasswordOrUpdateExistingPassword( user: grantor, - new SetInitialOrChangeExistingPasswordData + new SetInitialOrUpdateExistingPasswordData { MasterPasswordUnlock = unlockData, MasterPasswordAuthentication = authenticationData, diff --git a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs index 9fe7ea63a6d1..f85784da41c8 100644 --- a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs @@ -137,7 +137,7 @@ public async Task UpdateTdeOffboardingPasswordAsync( // We only want to be setting an initial master password here, if they already have one, // we are in an error state. - var identityResult = await _masterPasswordService.OnlyMutateUserSetInitialMasterPasswordAsync(user, new SetInitialPasswordData + var identityResult = await _masterPasswordService.MutateSetInitialMasterPasswordAsync(user, new SetInitialPasswordData { MasterPasswordUnlock = unlockData, MasterPasswordAuthentication = authenticationData, diff --git a/src/Core/Auth/UserFeatures/TempPassword/UpdateTempPasswordCommand.cs b/src/Core/Auth/UserFeatures/TempPassword/UpdateTempPasswordCommand.cs index 129985fd1321..1ca91dd06583 100644 --- a/src/Core/Auth/UserFeatures/TempPassword/UpdateTempPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TempPassword/UpdateTempPasswordCommand.cs @@ -30,7 +30,7 @@ public async Task UpdateTempPasswordAsync( throw new BadRequestException("User does not have a temporary password to update."); } - var result = await masterPasswordService.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, new UpdateExistingPasswordData + var result = await masterPasswordService.MutateUserUpdateExistingMasterPasswordAsync(user, new UpdateExistingPasswordData { MasterPasswordUnlock = unlockData, MasterPasswordAuthentication = authenticationData, diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialOrChangeExistingPasswordData.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialOrUpdateExistingPasswordData.cs similarity index 97% rename from src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialOrChangeExistingPasswordData.cs rename to src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialOrUpdateExistingPasswordData.cs index 1bda5cc43e7e..80c75d68141c 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialOrChangeExistingPasswordData.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialOrUpdateExistingPasswordData.cs @@ -2,7 +2,7 @@ namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; -public class SetInitialOrChangeExistingPasswordData +public class SetInitialOrUpdateExistingPasswordData { public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs index 4d80c75b1018..b139432cf117 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs @@ -67,7 +67,7 @@ public async Task FinishProvisionAsync(User user, } var updateUserData = - _masterPasswordService.BuildTransactionForSetInitialMasterPassword( + _masterPasswordService.BuildTransactionSetInitialMasterPassword( user, masterPasswordDataModel.ToSetInitialPasswordData()); diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs index ab51bd25b66c..faaab0895391 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs @@ -7,14 +7,21 @@ namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; /// /// This service bundles up all the ways we set an initial master password or update -/// an existing one into one place so we can perform the same +/// an existing one into one place so we can perform the same validation and timestamp setting. +/// +/// Meant to be used compositionally within other processes. Can be leveraged in controllers / commands / services. +/// Operations in here should be CRUD-like, not flow based logic with business logic. +/// +/// There should never be business logic in this service. It is to bottleneck all flows that change and set +/// initial password so we can perform validation of the conditions while setting an initial password and when updating +/// an existing password. /// public interface IMasterPasswordService { /// /// Inspects the user's current state and dispatches to either - /// or - /// accordingly. + /// or + /// accordingly. /// Mutates the object in memory only — no database write is performed. /// /// @@ -24,15 +31,15 @@ public interface IMasterPasswordService /// /// Combined cryptographic and authentication data that covers both the set-initial and /// update-existing paths. Converted internally via - /// or - /// . + /// or + /// . /// /// /// if the mutation succeeded; a failure result /// containing validation errors if ValidatePassword is set and the password /// fails the registered pipeline. /// - Task OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword(User user, SetInitialOrChangeExistingPasswordData setOrUpdatePasswordData); + Task MutateSetInitialPasswordOrUpdateExistingPassword(User user, SetInitialOrUpdateExistingPasswordData setOrUpdatePasswordData); /// /// Applies a new initial master password to the object in memory only — @@ -55,7 +62,7 @@ public interface IMasterPasswordService /// containing validation errors if ValidatePassword is set and the password /// fails the registered pipeline. /// - Task OnlyMutateUserSetInitialMasterPasswordAsync(User user, SetInitialPasswordData setInitialPasswordData); + Task MutateSetInitialMasterPasswordAsync(User user, SetInitialPasswordData setInitialPasswordData); /// /// Applies a new initial master password to the object and persists @@ -63,18 +70,18 @@ public interface IMasterPasswordService /// /// /// The user object to mutate and persist. Subject to the same preconditions as - /// . + /// . /// /// /// Cryptographic and authentication data required to set the initial password. See - /// for field details. + /// for field details. /// /// /// if the mutation and save succeeded; a failure result /// containing validation errors if ValidatePassword is set and the password /// fails the registered pipeline. /// - Task SetInitialMasterPasswordAndSaveUserAsync(User user, SetInitialPasswordData setInitialPasswordData); + Task SaveSetInitialMasterPasswordAsync(User user, SetInitialPasswordData setInitialPasswordData); /// /// Returns a deferred database write (as an delegate) for setting @@ -82,7 +89,7 @@ public interface IMasterPasswordService /// , which executes all supplied delegates /// within a single SQL transaction. Composing this delegate with others (e.g. cryptographic key /// writes) ensures every write succeeds or the entire batch rolls back atomically — a guarantee - /// cannot provide on its own. + /// cannot provide on its own. /// /// Note: despite the Async suffix, this method is synchronous — it constructs and returns /// the delegate without performing any I/O. @@ -93,13 +100,13 @@ public interface IMasterPasswordService /// /// /// Cryptographic and authentication data required to set the initial password. See - /// for field details. + /// for field details. /// /// /// An delegate suitable for inclusion in a batch passed to /// . /// - UpdateUserData BuildTransactionForSetInitialMasterPassword(User user, SetInitialPasswordData setInitialPasswordData); + UpdateUserData BuildTransactionSetInitialMasterPassword(User user, SetInitialPasswordData setInitialPasswordData); /// /// Applies a new master password over the user's existing one, mutating the @@ -122,7 +129,7 @@ public interface IMasterPasswordService /// containing validation errors if ValidatePassword is set and the password /// fails the registered pipeline. /// - Task OnlyMutateUserUpdateExistingMasterPasswordAsync(User user, UpdateExistingPasswordData updateExistingData); + Task MutateUserUpdateExistingMasterPasswordAsync(User user, UpdateExistingPasswordData updateExistingData); /// /// Applies a new master password over the user's existing one and persists the updated user @@ -130,16 +137,16 @@ public interface IMasterPasswordService /// /// /// The user object to mutate and persist. Subject to the same preconditions as - /// . + /// . /// /// /// Cryptographic and authentication data for the updated password. See - /// for field details. + /// for field details. /// /// /// if the mutation and save succeeded; a failure result /// containing validation errors if ValidatePassword is set and the password /// fails the registered pipeline. /// - Task UpdateExistingMasterPasswordAndSaveAsync(User user, UpdateExistingPasswordData updateExistingData); + Task SaveUpdateExistingMasterPasswordAsync(User user, UpdateExistingPasswordData updateExistingData); } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISelfServicePasswordChangeCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISelfServicePasswordChangeCommand.cs new file mode 100644 index 000000000000..739cf913f9de --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISelfServicePasswordChangeCommand.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; + +public interface ISelfServicePasswordChangeCommand +{ + +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs index b47d758868c8..7270d0b95650 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs @@ -23,20 +23,20 @@ public class MasterPasswordService( private readonly UserManager _userManager = userManager; private readonly ILogger _logger = logger; - public async Task OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( + public async Task MutateSetInitialPasswordOrUpdateExistingPassword( User user, - SetInitialOrChangeExistingPasswordData setOrUpdatePasswordData) + SetInitialOrUpdateExistingPasswordData setOrUpdatePasswordData) { IdentityResult mutationResult; if (user.HasMasterPassword()) { - mutationResult = await OnlyMutateUserUpdateExistingMasterPasswordAsync( + mutationResult = await MutateUserUpdateExistingMasterPasswordAsync( user, setOrUpdatePasswordData.ToUpdateExistingData()); } else { - mutationResult = await OnlyMutateUserSetInitialMasterPasswordAsync( + mutationResult = await MutateSetInitialMasterPasswordAsync( user, setOrUpdatePasswordData.ToSetInitialData()); } @@ -44,7 +44,7 @@ public async Task OnlyMutateEitherUpdateExistingPasswordOrSetIni return mutationResult; } - public async Task OnlyMutateUserSetInitialMasterPasswordAsync( + public async Task MutateSetInitialMasterPasswordAsync( User user, SetInitialPasswordData setInitialData) { @@ -81,12 +81,12 @@ public async Task OnlyMutateUserSetInitialMasterPasswordAsync( return IdentityResult.Success; } - public async Task SetInitialMasterPasswordAndSaveUserAsync( + public async Task SaveSetInitialMasterPasswordAsync( User user, SetInitialPasswordData setInitialData) { // No need to validate because we will validate in the sibling call here - var result = await OnlyMutateUserSetInitialMasterPasswordAsync(user, setInitialData); + var result = await MutateSetInitialMasterPasswordAsync(user, setInitialData); if (!result.Succeeded) { return result; @@ -97,7 +97,7 @@ public async Task SetInitialMasterPasswordAndSaveUserAsync( return IdentityResult.Success; } - public UpdateUserData BuildTransactionForSetInitialMasterPassword( + public UpdateUserData BuildTransactionSetInitialMasterPassword( User user, SetInitialPasswordData setInitialData) { @@ -114,7 +114,7 @@ public UpdateUserData BuildTransactionForSetInitialMasterPassword( return setMasterPasswordTask; } - public async Task OnlyMutateUserUpdateExistingMasterPasswordAsync( + public async Task MutateUserUpdateExistingMasterPasswordAsync( User user, UpdateExistingPasswordData updateExistingData) { @@ -145,12 +145,12 @@ public async Task OnlyMutateUserUpdateExistingMasterPasswordAsyn return IdentityResult.Success; } - public async Task UpdateExistingMasterPasswordAndSaveAsync( + public async Task SaveUpdateExistingMasterPasswordAsync( User user, UpdateExistingPasswordData updateExistingData) { // No need to validate because we will validate in the sibling call here. - var result = await OnlyMutateUserUpdateExistingMasterPasswordAsync(user, updateExistingData); + var result = await MutateUserUpdateExistingMasterPasswordAsync(user, updateExistingData); if (!result.Succeeded) { return result; diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/SelfServicePasswordChangeCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/SelfServicePasswordChangeCommand.cs new file mode 100644 index 000000000000..90507cf48f21 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/SelfServicePasswordChangeCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; + +public class SelfServicePasswordChangeCommand : ISelfServicePasswordChangeCommand +{ + +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs index 8314d645110f..17ac13100645 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs @@ -52,7 +52,7 @@ public async Task SetMasterPasswordAsync(User user, SetInitialMasterPasswordData throw new BadRequestException("User not found within organization."); } - var setMasterPasswordTask = _masterPasswordService.BuildTransactionForSetInitialMasterPassword(user, + var setMasterPasswordTask = _masterPasswordService.BuildTransactionSetInitialMasterPassword(user, new SetInitialPasswordData { MasterPasswordUnlock = masterPasswordDataModel.MasterPasswordUnlock, diff --git a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs index cfb9a95d7231..5ea7893dd0be 100644 --- a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs +++ b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs @@ -67,7 +67,7 @@ public async Task ChangeKdfAsync(User user, string masterPasswor var logoutOnKdfChange = !_featureService.IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange); // KM do we want this to be a new call in the master password service for ChangeKdf? - var updateExisingPasswordResult = await _masterPasswordService.UpdateExistingMasterPasswordAndSaveAsync(user, + var updateExisingPasswordResult = await _masterPasswordService.SaveUpdateExistingMasterPasswordAsync(user, new UpdateExistingPasswordData { MasterPasswordUnlock = unlockData, diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 49a6227e646b..76396a179281 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -52,6 +52,7 @@ Task ChangeEmailAsync(User user, string masterPassword, string n Task UpdatePremiumExpirationAsync(Guid userId, DateTime? expirationDate); Task GenerateLicenseAsync(User user, SubscriptionInfo subscriptionInfo = null, int? version = null); + // TODO: Evaluate moving to the new master password service PM-<> Task CheckPasswordAsync(User user, string password); /// /// Checks if the user has access to premium features, either through a personal subscription or through an organization. diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs index 5461486f34e3..d377996d71e6 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs @@ -48,13 +48,13 @@ public async Task RecoverAccountAsync_UserHasMasterPassword_CallsUpdate( // Assert Assert.True(result.Succeeded); await sutProvider.GetDependency().Received(1) - .OnlyMutateUserUpdateExistingMasterPasswordAsync( + .MutateUserUpdateExistingMasterPasswordAsync( Arg.Any(), Arg.Is(d => d.MasterPasswordUnlock == unlockData && d.MasterPasswordAuthentication == authenticationData)); await sutProvider.GetDependency().DidNotReceive() - .OnlyMutateUserSetInitialMasterPasswordAsync(Arg.Any(), Arg.Any()); + .MutateSetInitialMasterPasswordAsync(Arg.Any(), Arg.Any()); await AssertCommonSuccessSideEffectsAsync(sutProvider, user, organization, organizationUser); } @@ -81,13 +81,13 @@ public async Task RecoverAccountAsync_UserHasNoMasterPassword_CallsSetInitial( // Assert Assert.True(result.Succeeded); await sutProvider.GetDependency().Received(1) - .OnlyMutateUserSetInitialMasterPasswordAsync( + .MutateSetInitialMasterPasswordAsync( Arg.Any(), Arg.Is(d => d.MasterPasswordUnlock == unlockData && d.MasterPasswordAuthentication == authenticationData)); await sutProvider.GetDependency().DidNotReceive() - .OnlyMutateUserUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any()); + .MutateUserUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any()); await AssertCommonSuccessSideEffectsAsync(sutProvider, user, organization, organizationUser); } @@ -314,10 +314,10 @@ private static void SetupValidUser(SutProvider sutPr .GetUserByIdAsync(user.Id) .Returns(user); sutProvider.GetDependency() - .OnlyMutateUserUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any()) + .MutateUserUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Microsoft.AspNetCore.Identity.IdentityResult.Success); sutProvider.GetDependency() - .OnlyMutateUserSetInitialMasterPasswordAsync(Arg.Any(), Arg.Any()) + .MutateSetInitialMasterPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Microsoft.AspNetCore.Identity.IdentityResult.Success); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommandTests.cs index 6a9651c29bb1..adb061d2eca5 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommandTests.cs @@ -108,8 +108,8 @@ public async Task RecoverAccountAsync_ResetMasterPasswordOnly_Success( Assert.True(result.IsSuccess); await sutProvider.GetDependency().Received(1) - .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( - user, Arg.Any()); + .MutateSetInitialPasswordOrUpdateExistingPassword( + user, Arg.Any()); await sutProvider.GetDependency().Received(1).ReplaceAsync(user); @@ -210,8 +210,8 @@ await sutProvider.GetDependency().Received(1) .ResetAsync(user); await sutProvider.GetDependency().DidNotReceive() - .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( - Arg.Any(), Arg.Any()); + .MutateSetInitialPasswordOrUpdateExistingPassword( + Arg.Any(), Arg.Any()); await sutProvider.GetDependency().Received(1).SendAdminResetPasswordEmailAsync( Arg.Is(user.Email), @@ -320,8 +320,8 @@ public async Task RecoverAccountAsync_ResetBoth_Success( Assert.True(result.IsSuccess); await sutProvider.GetDependency().Received(1) - .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( - user, Arg.Any()); + .MutateSetInitialPasswordOrUpdateExistingPassword( + user, Arg.Any()); await sutProvider.GetDependency().Received(1).ReplaceAsync(user); @@ -415,8 +415,8 @@ public async Task RecoverAccountAsync_MasterPasswordServiceFails_ReturnsError( var failedResult = IdentityResult.Failed(new IdentityError { Description = "Password update failed" }); sutProvider.GetDependency() - .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( - user, Arg.Any()) + .MutateSetInitialPasswordOrUpdateExistingPassword( + user, Arg.Any()) .Returns(failedResult); var request = CreateNewRequest(organization.Id, organizationUser, @@ -545,8 +545,8 @@ private static void SetupSuccessfulPasswordUpdate(SutProvider sutProvider, User user) { sutProvider.GetDependency() - .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( - user, Arg.Any()) + .MutateSetInitialPasswordOrUpdateExistingPassword( + user, Arg.Any()) .Returns(IdentityResult.Success); } diff --git a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs index 455bc2d2aff4..e7149f4de4ef 100644 --- a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs @@ -1782,9 +1782,9 @@ public async Task FinishRecoveryTakeoverAsync_NonOrgUser_Success( await sutProvider.GetDependency() .Received(1) - .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword( + .MutateSetInitialPasswordOrUpdateExistingPassword( grantorUser, - Arg.Is(d => + Arg.Is(d => d.MasterPasswordUnlock == unlockData && d.MasterPasswordAuthentication == authenticationData)); await sutProvider.GetDependency() @@ -1826,7 +1826,7 @@ public async Task FinishRecoveryTakeoverAsync_OrgUser_NotOrganizationOwner_Remov await sutProvider.GetDependency() .Received(1) - .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword(grantorUser, Arg.Any()); + .MutateSetInitialPasswordOrUpdateExistingPassword(grantorUser, Arg.Any()); await sutProvider.GetDependency() .Received(1) .ReplaceAsync(Arg.Is(u => u.VerifyDevices == false)); @@ -1865,7 +1865,7 @@ public async Task FinishRecoveryTakeoverAsync_OrgUser_IsOrganizationOwner_NotRem await sutProvider.GetDependency() .Received(1) - .OnlyMutateEitherUpdateExistingPasswordOrSetInitialPassword(grantorUser, Arg.Any()); + .MutateSetInitialPasswordOrUpdateExistingPassword(grantorUser, Arg.Any()); await sutProvider.GetDependency() .Received(1) .ReplaceAsync(Arg.Is(u => u.VerifyDevices == false)); diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs index 6478de1fe05c..966f16e10941 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs @@ -50,7 +50,7 @@ public async Task SetInitialMasterPassword_Success(User user, string masterPassw }; // Act - await sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, setInitialData); + await sutProvider.Sut.MutateSetInitialMasterPasswordAsync(user, setInitialData); // Assert Assert.Equal(expectedHash, user.MasterPassword); @@ -92,7 +92,7 @@ public async Task SetInitialMasterPassword_SetsMasterPasswordHint(User user, str }; // Act - await sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, setInitialData); + await sutProvider.Sut.MutateSetInitialMasterPasswordAsync(user, setInitialData); // Assert Assert.Equal(hint, user.MasterPasswordHint); @@ -125,7 +125,7 @@ public async Task SetInitialMasterPassword_ThrowsWhenMasterPasswordAlreadySet(Us // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, setInitialData)); + sutProvider.Sut.MutateSetInitialMasterPasswordAsync(user, setInitialData)); Assert.Equal("User already has a master password set.", exception.Message); } @@ -156,7 +156,7 @@ public async Task SetInitialMasterPassword_ThrowsWhenKeyAlreadySet(User user, st // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.OnlyMutateUserSetInitialMasterPasswordAsync(user, setInitialData)); + sutProvider.Sut.MutateSetInitialMasterPasswordAsync(user, setInitialData)); Assert.Equal("User already has a key set.", exception.Message); } @@ -189,7 +189,7 @@ public async Task SetInitialMasterPasswordAsync_CallsMutationThenSavesUser( }; // Act - await sutProvider.Sut.SetInitialMasterPasswordAndSaveUserAsync(user, setInitialData); + await sutProvider.Sut.SaveSetInitialMasterPasswordAsync(user, setInitialData); // Assert: mutation was applied Assert.NotNull(user.MasterPassword); @@ -243,7 +243,7 @@ public async Task UpdateMasterPassword_Success(User user, string masterPasswordH }; // Act - await sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, updateData); + await sutProvider.Sut.MutateUserUpdateExistingMasterPasswordAsync(user, updateData); // Assert Assert.Equal(expectedHash, user.MasterPassword); @@ -291,7 +291,7 @@ public async Task UpdateMasterPassword_SetsMasterPasswordHint(User user, string }; // Act - await sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, updateData); + await sutProvider.Sut.MutateUserUpdateExistingMasterPasswordAsync(user, updateData); // Assert Assert.Equal(hint, user.MasterPasswordHint); @@ -322,7 +322,7 @@ public async Task UpdateMasterPassword_ThrowsWhenNoExistingPassword(User user, s // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, updateData)); + sutProvider.Sut.MutateUserUpdateExistingMasterPasswordAsync(user, updateData)); Assert.Equal("User does not have an existing master password to update.", exception.Message); } @@ -362,7 +362,7 @@ public async Task UpdateMasterPassword_ThrowsWhenKdfMismatch(User user, string m // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.OnlyMutateUserUpdateExistingMasterPasswordAsync(user, updateData)); + sutProvider.Sut.MutateUserUpdateExistingMasterPasswordAsync(user, updateData)); } [Theory, BitAutoData] @@ -399,7 +399,7 @@ public async Task UpdateMasterPasswordAsync_CallsMutationThenSavesUser(User user }; // Act - await sutProvider.Sut.UpdateExistingMasterPasswordAndSaveAsync(user, updateData); + await sutProvider.Sut.SaveUpdateExistingMasterPasswordAsync(user, updateData); // Assert: mutation was applied Assert.NotNull(user.MasterPassword); diff --git a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs index 0ef3c22dad2d..c17efc8f4a07 100644 --- a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs +++ b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs @@ -27,7 +27,7 @@ public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider s sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); sutProvider.GetDependency() - .UpdateExistingMasterPasswordAndSaveAsync(Arg.Any(), Arg.Any()) + .SaveUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Success)); var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; @@ -47,7 +47,7 @@ public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider s await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); await sutProvider.GetDependency().Received(1) - .UpdateExistingMasterPasswordAndSaveAsync(user, Arg.Is(d => + .SaveUpdateExistingMasterPasswordAsync(user, Arg.Is(d => d.MasterPasswordAuthentication == authenticationData && d.MasterPasswordUnlock == unlockData )); @@ -130,14 +130,14 @@ public async Task sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); sutProvider.GetDependency() - .UpdateExistingMasterPasswordAndSaveAsync(Arg.Any(), Arg.Any()) + .SaveUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Success)); sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(false); await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); await sutProvider.GetDependency().Received(1) - .UpdateExistingMasterPasswordAndSaveAsync(user, Arg.Is(d => + .SaveUpdateExistingMasterPasswordAsync(user, Arg.Is(d => d.MasterPasswordAuthentication == authenticationData && d.MasterPasswordUnlock == unlockData && d.RefreshStamp == true @@ -174,14 +174,14 @@ public async Task sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); sutProvider.GetDependency() - .UpdateExistingMasterPasswordAndSaveAsync(Arg.Any(), Arg.Any()) + .SaveUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Success)); sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); await sutProvider.GetDependency().Received(1) - .UpdateExistingMasterPasswordAndSaveAsync(user, Arg.Is(d => + .SaveUpdateExistingMasterPasswordAsync(user, Arg.Is(d => d.MasterPasswordAuthentication == authenticationData && d.MasterPasswordUnlock == unlockData && d.RefreshStamp == false @@ -279,7 +279,7 @@ public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvi .Returns(Task.FromResult(true)); var failedResult = IdentityResult.Failed(new IdentityError { Code = "TestFail", Description = "Test fail" }); sutProvider.GetDependency() - .UpdateExistingMasterPasswordAndSaveAsync(Arg.Any(), Arg.Any()) + .SaveUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(failedResult)); var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; From 04a6846beaa9c7d90bbe09f9a8e9fc1457a9ec77 Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Mon, 13 Apr 2026 15:04:31 -0400 Subject: [PATCH 19/20] feat(master-password): Master Password Service - Added in self service password change command --- .../Auth/Controllers/AccountsController.cs | 26 +++++----------- .../ISelfServicePasswordChangeCommand.cs | 13 ++++++-- .../SelfServicePasswordChangeCommand.cs | 30 +++++++++++++++++-- .../UserServiceCollectionExtensions.cs | 1 + 4 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 349163a706ac..5383d50ca4dc 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -15,7 +15,6 @@ using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; using Bit.Core.Auth.UserFeatures.TempPassword.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; -using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -39,7 +38,7 @@ public class AccountsController( IOrganizationUserRepository organizationUserRepository, IProviderUserRepository providerUserRepository, IUserService userService, - IMasterPasswordService masterPasswordService, + ISelfServicePasswordChangeCommand selfServicePasswordChangeCommand, IPolicyService policyService, IFinishSsoJitProvisionMasterPasswordCommand finishSsoJitProvisionMasterPasswordCommand, ISetInitialMasterPasswordCommandV1 setInitialMasterPasswordCommandV1, @@ -56,7 +55,7 @@ public class AccountsController( private readonly IOrganizationUserRepository _organizationUserRepository = organizationUserRepository; private readonly IProviderUserRepository _providerUserRepository = providerUserRepository; private readonly IUserService _userService = userService; - private readonly IMasterPasswordService _masterPasswordService = masterPasswordService; + private readonly ISelfServicePasswordChangeCommand _selfServicePasswordChangeCommand = selfServicePasswordChangeCommand; private readonly IPolicyService _policyService = policyService; private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1 = setInitialMasterPasswordCommandV1; private readonly IFinishSsoJitProvisionMasterPasswordCommand _finishSsoJitProvisionMasterPasswordCommand = finishSsoJitProvisionMasterPasswordCommand; @@ -193,21 +192,12 @@ public async Task PostPassword([FromBody] PasswordRequestModel model) IdentityResult result; if (model.RequestHasNewDataTypes()) { - // Make a self service password change command - - if (await _userService.CheckPasswordAsync(user, model.AuthenticationData!.MasterPasswordAuthenticationHash)) - { - result = await _masterPasswordService.SaveUpdateExistingMasterPasswordAsync(user, new UpdateExistingPasswordData - { - MasterPasswordUnlock = model.UnlockData!.ToData(), - MasterPasswordAuthentication = model.AuthenticationData!.ToData(), - MasterPasswordHint = model.MasterPasswordHint - }); - } - else - { - throw new BadRequestException("Passwords do not match."); - } + result = await _selfServicePasswordChangeCommand.ChangePasswordAsync( + user, + model.MasterPasswordHash, + model.UnlockData!.ToData(), + model.AuthenticationData!.ToData(), + model.MasterPasswordHint); } // To be removed in PM-33141 else diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISelfServicePasswordChangeCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISelfServicePasswordChangeCommand.cs index 739cf913f9de..1275cde77647 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISelfServicePasswordChangeCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISelfServicePasswordChangeCommand.cs @@ -1,6 +1,15 @@ -namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; public interface ISelfServicePasswordChangeCommand { - + Task ChangePasswordAsync( + User user, + string masterPasswordHash, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, + string? masterPasswordHint); } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/SelfServicePasswordChangeCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/SelfServicePasswordChangeCommand.cs index 90507cf48f21..8faf5cf71d10 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/SelfServicePasswordChangeCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/SelfServicePasswordChangeCommand.cs @@ -1,8 +1,34 @@ -using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Services; +using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; -public class SelfServicePasswordChangeCommand : ISelfServicePasswordChangeCommand +public class SelfServicePasswordChangeCommand( + IUserService userService, + IMasterPasswordService masterPasswordService, + IdentityErrorDescriber identityErrorDescriber) : ISelfServicePasswordChangeCommand { + public async Task ChangePasswordAsync( + User user, + string masterPasswordHash, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, + string? masterPasswordHint) + { + if (!await userService.CheckPasswordAsync(user, masterPasswordHash)) + { + return IdentityResult.Failed(identityErrorDescriber.PasswordMismatch()); + } + return await masterPasswordService.SaveUpdateExistingMasterPasswordAsync(user, new UpdateExistingPasswordData + { + MasterPasswordUnlock = unlockData, + MasterPasswordAuthentication = authenticationData, + MasterPasswordHint = masterPasswordHint + }); + } } diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index cef0ff94e485..742aa1945896 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -59,6 +59,7 @@ private static void AddUserPasswordCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddTdeOffboardingPasswordCommands(this IServiceCollection services) From 3b8ce5774710817f836f30c4b9882ca318d600da Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Mon, 13 Apr 2026 16:50:06 -0400 Subject: [PATCH 20/20] feat(master-password): Master Password Service - Added in change kdf specific command to do the correct validation --- .../Auth/Controllers/AccountsController.cs | 5 +- .../Controllers/EmergencyAccessController.cs | 20 +++++++- .../AdminRecoverAccountCommand.cs | 2 +- .../v2/AdminRecoverAccountCommand.cs | 2 +- .../EmergencyAccess/EmergencyAccessService.cs | 11 ++++- .../IEmergencyAccessService.cs | 3 +- .../TempPassword/UpdateTempPasswordCommand.cs | 2 +- .../Data/UpdateExistingPasswordAndKdfData.cs | 48 +++++++++++++++++++ ...ishSsoJitProvisionMasterPasswordCommand.cs | 2 +- .../Interfaces/IMasterPasswordService.cs | 14 +++--- .../MasterPasswordService.cs | 41 ++++++++++++++-- .../TdeSetPasswordCommand.cs | 2 +- .../Kdf/Implementations/ChangeKdfCommand.cs | 5 +- .../AdminRecoverAccountCommandTests.cs | 6 +-- .../v2/AdminRecoverAccountCommandTests.cs | 10 ++-- .../EmergencyAccessServiceTests.cs | 6 +-- .../MasterPasswordServiceTests.cs | 8 ++-- 17 files changed, 147 insertions(+), 40 deletions(-) create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordAndKdfData.cs diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 5383d50ca4dc..3f7f0cd2e56b 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -308,7 +308,10 @@ public async Task PostKdf([FromBody] ChangeKdfRequestModel model) throw new BadRequestException("AuthenticationData and UnlockData must be provided."); } - var result = await _changeKdfCommand.ChangeKdfAsync(user, model.MasterPasswordHash, model.AuthenticationData.ToData(), model.UnlockData.ToData()); + var result = await _changeKdfCommand.ChangeKdfAsync(user, + model.MasterPasswordHash, + model.AuthenticationData.ToData(), + model.UnlockData.ToData()); if (result.Succeeded) { return; diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index 599a001e036e..15bdcd81e7e0 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -13,6 +13,7 @@ using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; @@ -170,18 +171,33 @@ public async Task Takeover(Guid id) } [HttpPost("{id}/password")] - public async Task Password(Guid id, [FromBody] EmergencyAccessPasswordRequestModel model) + public async Task Password(Guid id, [FromBody] EmergencyAccessPasswordRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); + IdentityResult identityResult; if (model.RequestHasNewDataTypes()) { - await _emergencyAccessService.FinishRecoveryTakeoverAsync(id, user, model.UnlockData!.ToData(), model.AuthenticationData!.ToData()); + identityResult = await _emergencyAccessService.FinishRecoveryTakeoverAsync(id, user, model.UnlockData!.ToData(), model.AuthenticationData!.ToData()); + + if (identityResult.Succeeded) + { + return TypedResults.Ok(); + } + + foreach (var error in identityResult.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + return TypedResults.BadRequest(ModelState); } // To be removed in PM-33141 else { await _emergencyAccessService.FinishRecoveryTakeoverAsync(id, user, model.NewMasterPasswordHash!, model.Key!); + + return TypedResults.Ok(); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs index 80704f4619ba..4b1c230fef4e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs @@ -129,7 +129,7 @@ public async Task RecoverAccountAsync( // those who do not. TDE users can be recovered and will not have a password if (user.HasMasterPassword()) { - mutationResult = await masterPasswordService.MutateUserUpdateExistingMasterPasswordAsync( + mutationResult = await masterPasswordService.MutateUpdateExistingMasterPasswordAsync( user, new UpdateExistingPasswordData { diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs index 66e319b75953..a908e7ee9b2d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs @@ -144,7 +144,7 @@ private async Task HandlePayloadsWithUnlockAndAuthenticationDataA // We can recover an account for users who both have a master password and // those who do not. TDE users can be account recovered which will not have // an initial master password set. - var identityResultFromMutation = await masterPasswordService.MutateSetInitialPasswordOrUpdateExistingPassword( + var identityResultFromMutation = await masterPasswordService.MutateSetInitialOrUpdateExistingMasterPassword( user, new SetInitialOrUpdateExistingPasswordData { diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs index 1c5740d0b1b0..b08766d1fc13 100644 --- a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs @@ -22,6 +22,7 @@ using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Services; +using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.EmergencyAccess; @@ -396,7 +397,7 @@ public async Task FinishRecoveryTakeoverAsync(Guid emergencyAccessId, User grant } } - public async Task FinishRecoveryTakeoverAsync( + public async Task FinishRecoveryTakeoverAsync( Guid emergencyAccessId, User granteeUser, MasterPasswordUnlockData unlockData, @@ -416,7 +417,7 @@ public async Task FinishRecoveryTakeoverAsync( throw new BadRequestException("Grantor not found when trying to finish recovery takeover."); } - await _masterPasswordService.MutateSetInitialPasswordOrUpdateExistingPassword( + var identityResult = await _masterPasswordService.MutateSetInitialOrUpdateExistingMasterPasswordAsync( user: grantor, new SetInitialOrUpdateExistingPasswordData { @@ -424,6 +425,11 @@ await _masterPasswordService.MutateSetInitialPasswordOrUpdateExistingPassword( MasterPasswordAuthentication = authenticationData, }); + if (!identityResult.Succeeded) + { + return identityResult; + } + // Side effects that we still need to run when performing emergency access. // Disable TwoFactor providers since they will otherwise block logins @@ -442,6 +448,7 @@ await _masterPasswordService.MutateSetInitialPasswordOrUpdateExistingPassword( await _removeOrganizationUserCommand.RemoveUserAsync(o.OrganizationId, grantor.Id); } } + return IdentityResult.Success; } public async Task SendNotificationsAsync() diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs index ce13b4a93080..4032642d9937 100644 --- a/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs @@ -6,6 +6,7 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Services; using Bit.Core.Vault.Models.Data; +using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.EmergencyAccess; @@ -128,7 +129,7 @@ public interface IEmergencyAccessService /// /// /// - Task FinishRecoveryTakeoverAsync(Guid emergencyAccessId, User granteeUser, MasterPasswordUnlockData unlockData, MasterPasswordAuthenticationData authenticationData); + Task FinishRecoveryTakeoverAsync(Guid emergencyAccessId, User granteeUser, MasterPasswordUnlockData unlockData, MasterPasswordAuthenticationData authenticationData); /// /// sends a reminder email that there is a pending request for recovery. /// diff --git a/src/Core/Auth/UserFeatures/TempPassword/UpdateTempPasswordCommand.cs b/src/Core/Auth/UserFeatures/TempPassword/UpdateTempPasswordCommand.cs index 1ca91dd06583..f0640c43b131 100644 --- a/src/Core/Auth/UserFeatures/TempPassword/UpdateTempPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TempPassword/UpdateTempPasswordCommand.cs @@ -30,7 +30,7 @@ public async Task UpdateTempPasswordAsync( throw new BadRequestException("User does not have a temporary password to update."); } - var result = await masterPasswordService.MutateUserUpdateExistingMasterPasswordAsync(user, new UpdateExistingPasswordData + var result = await masterPasswordService.MutateUpdateExistingMasterPasswordAsync(user, new UpdateExistingPasswordData { MasterPasswordUnlock = unlockData, MasterPasswordAuthentication = authenticationData, diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordAndKdfData.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordAndKdfData.cs new file mode 100644 index 000000000000..f96d12cc3959 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordAndKdfData.cs @@ -0,0 +1,48 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; + +public class UpdateExistingPasswordAndKdfData +{ + public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } + public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } + + /// + /// When true, runs the new password hash through the registered + /// pipeline before hashing. + /// Set to false only in flows where password policy validation has already been enforced + /// (e.g. admin-initiated recovery). Defaults to true. + /// + public bool ValidatePassword { get; set; } = true; + /// + /// When true, rotates , which invalidates + /// all active sessions and authentication tokens for the user. Set to false only when + /// intentionally preserving existing sessions. Defaults to true. + /// + public bool RefreshStamp { get; set; } = true; + + public string? MasterPasswordHint { get; set; } = null; + + public void ValidateDataForUser(User user) + { + // Validate that the user has a master password already, if not then they shouldn't be updating they should + // be setting initial. + if (!user.HasMasterPassword()) + { + throw new BadRequestException("User does not have an existing master password to update."); + } + + if (user.UsesKeyConnector) + { + throw new BadRequestException("Cannot update password of a user with Key Connector."); + } + + // Do not validate if kdf is the same here on the user because we are changing it. + + // Validate Salt is unchanged for user + MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user); + MasterPasswordUnlock.ValidateSaltUnchangedForUser(user); + } +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs index b139432cf117..d48e6a1e1be8 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs @@ -67,7 +67,7 @@ public async Task FinishProvisionAsync(User user, } var updateUserData = - _masterPasswordService.BuildTransactionSetInitialMasterPassword( + _masterPasswordService.BuildUpdateUserDelegateSetInitialMasterPassword( user, masterPasswordDataModel.ToSetInitialPasswordData()); diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs index faaab0895391..9e5e5d628ef0 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs @@ -21,7 +21,7 @@ public interface IMasterPasswordService /// /// Inspects the user's current state and dispatches to either /// or - /// accordingly. + /// accordingly. /// Mutates the object in memory only — no database write is performed. /// /// @@ -39,7 +39,7 @@ public interface IMasterPasswordService /// containing validation errors if ValidatePassword is set and the password /// fails the registered pipeline. /// - Task MutateSetInitialPasswordOrUpdateExistingPassword(User user, SetInitialOrUpdateExistingPasswordData setOrUpdatePasswordData); + Task MutateSetInitialOrUpdateExistingMasterPasswordAsync(User user, SetInitialOrUpdateExistingPasswordData setOrUpdatePasswordData); /// /// Applies a new initial master password to the object in memory only — @@ -106,7 +106,7 @@ public interface IMasterPasswordService /// An delegate suitable for inclusion in a batch passed to /// . /// - UpdateUserData BuildTransactionSetInitialMasterPassword(User user, SetInitialPasswordData setInitialPasswordData); + UpdateUserData BuildUpdateUserDelegateSetInitialMasterPassword(User user, SetInitialPasswordData setInitialPasswordData); /// /// Applies a new master password over the user's existing one, mutating the @@ -129,7 +129,9 @@ public interface IMasterPasswordService /// containing validation errors if ValidatePassword is set and the password /// fails the registered pipeline. /// - Task MutateUserUpdateExistingMasterPasswordAsync(User user, UpdateExistingPasswordData updateExistingData); + Task MutateUpdateExistingMasterPasswordAsync(User user, UpdateExistingPasswordData updateExistingData); + + Task SaveUpdateExistingMasterPasswordAndKdfAsync(User user, UpdateExistingPasswordAndKdfData updateExistingExistingData); /// /// Applies a new master password over the user's existing one and persists the updated user @@ -137,11 +139,11 @@ public interface IMasterPasswordService /// /// /// The user object to mutate and persist. Subject to the same preconditions as - /// . + /// . /// /// /// Cryptographic and authentication data for the updated password. See - /// for field details. + /// for field details. /// /// /// if the mutation and save succeeded; a failure result diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs index 7270d0b95650..e6b5c403b24a 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs @@ -23,14 +23,14 @@ public class MasterPasswordService( private readonly UserManager _userManager = userManager; private readonly ILogger _logger = logger; - public async Task MutateSetInitialPasswordOrUpdateExistingPassword( + public async Task MutateSetInitialOrUpdateExistingMasterPasswordAsync( User user, SetInitialOrUpdateExistingPasswordData setOrUpdatePasswordData) { IdentityResult mutationResult; if (user.HasMasterPassword()) { - mutationResult = await MutateUserUpdateExistingMasterPasswordAsync( + mutationResult = await MutateUpdateExistingMasterPasswordAsync( user, setOrUpdatePasswordData.ToUpdateExistingData()); } @@ -97,7 +97,7 @@ public async Task SaveSetInitialMasterPasswordAsync( return IdentityResult.Success; } - public UpdateUserData BuildTransactionSetInitialMasterPassword( + public UpdateUserData BuildUpdateUserDelegateSetInitialMasterPassword( User user, SetInitialPasswordData setInitialData) { @@ -114,7 +114,7 @@ public UpdateUserData BuildTransactionSetInitialMasterPassword( return setMasterPasswordTask; } - public async Task MutateUserUpdateExistingMasterPasswordAsync( + public async Task MutateUpdateExistingMasterPasswordAsync( User user, UpdateExistingPasswordData updateExistingData) { @@ -145,12 +145,43 @@ public async Task MutateUserUpdateExistingMasterPasswordAsync( return IdentityResult.Success; } + public async Task SaveUpdateExistingMasterPasswordAndKdfAsync( + User user, + UpdateExistingPasswordAndKdfData updateExistingExistingData) + { + // Start by validating the update payload + updateExistingExistingData.ValidateDataForUser(user); + + var result = await UpdateExistingPasswordHashAsync( + user, + updateExistingExistingData.MasterPasswordAuthentication.MasterPasswordAuthenticationHash, + updateExistingExistingData.ValidatePassword, + updateExistingExistingData.RefreshStamp); + + if (!result.Succeeded) + { + return result; + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + user.Key = updateExistingExistingData.MasterPasswordUnlock.MasterKeyWrappedUserKey; + + // Always override the master password hint, even if it's null. + user.MasterPasswordHint = updateExistingExistingData.MasterPasswordHint; + + user.LastPasswordChangeDate = now; + user.RevisionDate = user.AccountRevisionDate = now; + + return IdentityResult.Success; + } + public async Task SaveUpdateExistingMasterPasswordAsync( User user, UpdateExistingPasswordData updateExistingData) { // No need to validate because we will validate in the sibling call here. - var result = await MutateUserUpdateExistingMasterPasswordAsync(user, updateExistingData); + var result = await MutateUpdateExistingMasterPasswordAsync(user, updateExistingData); if (!result.Succeeded) { return result; diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs index 17ac13100645..add87e2c4e3e 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs @@ -52,7 +52,7 @@ public async Task SetMasterPasswordAsync(User user, SetInitialMasterPasswordData throw new BadRequestException("User not found within organization."); } - var setMasterPasswordTask = _masterPasswordService.BuildTransactionSetInitialMasterPassword(user, + var setMasterPasswordTask = _masterPasswordService.BuildUpdateUserDelegateSetInitialMasterPassword(user, new SetInitialPasswordData { MasterPasswordUnlock = masterPasswordDataModel.MasterPasswordUnlock, diff --git a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs index 5ea7893dd0be..319876cce63f 100644 --- a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs +++ b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs @@ -66,9 +66,8 @@ public async Task ChangeKdfAsync(User user, string masterPasswor var logoutOnKdfChange = !_featureService.IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange); - // KM do we want this to be a new call in the master password service for ChangeKdf? - var updateExisingPasswordResult = await _masterPasswordService.SaveUpdateExistingMasterPasswordAsync(user, - new UpdateExistingPasswordData + var updateExisingPasswordResult = await _masterPasswordService.SaveUpdateExistingMasterPasswordAndKdfAsync(user, + new UpdateExistingPasswordAndKdfData { MasterPasswordUnlock = unlockData, MasterPasswordAuthentication = authenticationData, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs index d377996d71e6..271dd5360b4b 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs @@ -48,7 +48,7 @@ public async Task RecoverAccountAsync_UserHasMasterPassword_CallsUpdate( // Assert Assert.True(result.Succeeded); await sutProvider.GetDependency().Received(1) - .MutateUserUpdateExistingMasterPasswordAsync( + .MutateUpdateExistingMasterPasswordAsync( Arg.Any(), Arg.Is(d => d.MasterPasswordUnlock == unlockData && @@ -87,7 +87,7 @@ await sutProvider.GetDependency().Received(1) d.MasterPasswordUnlock == unlockData && d.MasterPasswordAuthentication == authenticationData)); await sutProvider.GetDependency().DidNotReceive() - .MutateUserUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any()); + .MutateUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any()); await AssertCommonSuccessSideEffectsAsync(sutProvider, user, organization, organizationUser); } @@ -314,7 +314,7 @@ private static void SetupValidUser(SutProvider sutPr .GetUserByIdAsync(user.Id) .Returns(user); sutProvider.GetDependency() - .MutateUserUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any()) + .MutateUpdateExistingMasterPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Microsoft.AspNetCore.Identity.IdentityResult.Success); sutProvider.GetDependency() .MutateSetInitialMasterPasswordAsync(Arg.Any(), Arg.Any()) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommandTests.cs index adb061d2eca5..a356699cccd3 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommandTests.cs @@ -108,7 +108,7 @@ public async Task RecoverAccountAsync_ResetMasterPasswordOnly_Success( Assert.True(result.IsSuccess); await sutProvider.GetDependency().Received(1) - .MutateSetInitialPasswordOrUpdateExistingPassword( + .MutateSetInitialOrUpdateExistingMasterPassword( user, Arg.Any()); await sutProvider.GetDependency().Received(1).ReplaceAsync(user); @@ -210,7 +210,7 @@ await sutProvider.GetDependency().Received(1) .ResetAsync(user); await sutProvider.GetDependency().DidNotReceive() - .MutateSetInitialPasswordOrUpdateExistingPassword( + .MutateSetInitialOrUpdateExistingMasterPassword( Arg.Any(), Arg.Any()); await sutProvider.GetDependency().Received(1).SendAdminResetPasswordEmailAsync( @@ -320,7 +320,7 @@ public async Task RecoverAccountAsync_ResetBoth_Success( Assert.True(result.IsSuccess); await sutProvider.GetDependency().Received(1) - .MutateSetInitialPasswordOrUpdateExistingPassword( + .MutateSetInitialOrUpdateExistingMasterPassword( user, Arg.Any()); await sutProvider.GetDependency().Received(1).ReplaceAsync(user); @@ -415,7 +415,7 @@ public async Task RecoverAccountAsync_MasterPasswordServiceFails_ReturnsError( var failedResult = IdentityResult.Failed(new IdentityError { Description = "Password update failed" }); sutProvider.GetDependency() - .MutateSetInitialPasswordOrUpdateExistingPassword( + .MutateSetInitialOrUpdateExistingMasterPassword( user, Arg.Any()) .Returns(failedResult); @@ -545,7 +545,7 @@ private static void SetupSuccessfulPasswordUpdate(SutProvider sutProvider, User user) { sutProvider.GetDependency() - .MutateSetInitialPasswordOrUpdateExistingPassword( + .MutateSetInitialOrUpdateExistingMasterPassword( user, Arg.Any()) .Returns(IdentityResult.Success); } diff --git a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs index e7149f4de4ef..8c81e2d674d9 100644 --- a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs @@ -1782,7 +1782,7 @@ public async Task FinishRecoveryTakeoverAsync_NonOrgUser_Success( await sutProvider.GetDependency() .Received(1) - .MutateSetInitialPasswordOrUpdateExistingPassword( + .MutateSetInitialOrUpdateExistingMasterPasswordAsync( grantorUser, Arg.Is(d => d.MasterPasswordUnlock == unlockData && @@ -1826,7 +1826,7 @@ public async Task FinishRecoveryTakeoverAsync_OrgUser_NotOrganizationOwner_Remov await sutProvider.GetDependency() .Received(1) - .MutateSetInitialPasswordOrUpdateExistingPassword(grantorUser, Arg.Any()); + .MutateSetInitialOrUpdateExistingMasterPasswordAsync(grantorUser, Arg.Any()); await sutProvider.GetDependency() .Received(1) .ReplaceAsync(Arg.Is(u => u.VerifyDevices == false)); @@ -1865,7 +1865,7 @@ public async Task FinishRecoveryTakeoverAsync_OrgUser_IsOrganizationOwner_NotRem await sutProvider.GetDependency() .Received(1) - .MutateSetInitialPasswordOrUpdateExistingPassword(grantorUser, Arg.Any()); + .MutateSetInitialOrUpdateExistingMasterPasswordAsync(grantorUser, Arg.Any()); await sutProvider.GetDependency() .Received(1) .ReplaceAsync(Arg.Is(u => u.VerifyDevices == false)); diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs index 966f16e10941..504d77bfbcb1 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs @@ -243,7 +243,7 @@ public async Task UpdateMasterPassword_Success(User user, string masterPasswordH }; // Act - await sutProvider.Sut.MutateUserUpdateExistingMasterPasswordAsync(user, updateData); + await sutProvider.Sut.MutateUpdateExistingMasterPasswordAsync(user, updateData); // Assert Assert.Equal(expectedHash, user.MasterPassword); @@ -291,7 +291,7 @@ public async Task UpdateMasterPassword_SetsMasterPasswordHint(User user, string }; // Act - await sutProvider.Sut.MutateUserUpdateExistingMasterPasswordAsync(user, updateData); + await sutProvider.Sut.MutateUpdateExistingMasterPasswordAsync(user, updateData); // Assert Assert.Equal(hint, user.MasterPasswordHint); @@ -322,7 +322,7 @@ public async Task UpdateMasterPassword_ThrowsWhenNoExistingPassword(User user, s // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.MutateUserUpdateExistingMasterPasswordAsync(user, updateData)); + sutProvider.Sut.MutateUpdateExistingMasterPasswordAsync(user, updateData)); Assert.Equal("User does not have an existing master password to update.", exception.Message); } @@ -362,7 +362,7 @@ public async Task UpdateMasterPassword_ThrowsWhenKdfMismatch(User user, string m // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.MutateUserUpdateExistingMasterPasswordAsync(user, updateData)); + sutProvider.Sut.MutateUpdateExistingMasterPasswordAsync(user, updateData)); } [Theory, BitAutoData]