From c6331ba0e7bdc237a6744c5fa8a9bedfbe360c6d Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Thu, 9 Apr 2026 16:43:40 -0400 Subject: [PATCH] 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 62b826aa71..0356bd23a9 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -41,6 +41,7 @@ using Bit.Core.Utilities; 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 class OrganizationUsersController : BaseAdminConsoleController 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 ebf2650648..05e58bf6c9 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -13,6 +13,7 @@ using Bit.Core.Auth.Identity; 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 class AccountsController( 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 class AccountsController( 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 6ef8aa8ae7..acef2d7a50 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs @@ -3,7 +3,7 @@ using Bit.Core.KeyManagement.Models.Api.Request; 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 1bcc1af618..f56fb4fd40 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 7fcd933b08..27b87bb227 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs @@ -29,8 +29,8 @@ public interface IAdminRecoverAccountCommand /// /// 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 192df378be..d578b7b905 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/v2/AdminRecoverAccountCommand.cs @@ -68,6 +68,7 @@ public class AdminRecoverAccountCommand( 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 8e654d996c..2035e66ae7 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 record RecoverAccountRequest : IValidatableObject { 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 1aeda245df..babe62a4d7 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 7470387055..9fe7ea63a6 100644 --- a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs @@ -46,7 +46,7 @@ public class TdeOffboardingPasswordCommand : ITdeOffboardingPasswordCommand } [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 class TdeOffboardingPasswordCommand : ITdeOffboardingPasswordCommand 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 class TdeOffboardingPasswordCommand : ITdeOffboardingPasswordCommand 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 0000000000..316416d8ac --- /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 0000000000..129985fd13 --- /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 a115f1c736..cef0ff94e4 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -5,6 +5,8 @@ using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; 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 class UserServiceCollectionExtensions services.AddDeviceTrustCommands(); services.AddEmergencyAccessCommands(); services.AddUserPasswordCommands(); + services.AddUpdateTempPasswordCommands(); services.AddUserRegistrationCommands(); services.AddWebAuthnLoginCommands(); services.AddTdeOffboardingPasswordCommands(); @@ -63,6 +66,11 @@ public static class UserServiceCollectionExtensions 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 cd51350c6c..49a6227e64 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -35,6 +35,7 @@ public interface IUserService 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 262cc9266f..0b71278075 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -658,6 +658,7 @@ public class UserService : UserManager, IUserService 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)