feat(master-password): Master Password Service - Added update temp password update

This commit is contained in:
Patrick Pimentel
2026-04-09 16:43:40 -04:00
parent 8fb2b7186c
commit c6331ba0e7
14 changed files with 142 additions and 46 deletions

View File

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

View File

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

View File

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

View File

@@ -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<ValidationResult> Validate(ValidationContext validationContext)
{

View File

@@ -29,8 +29,8 @@ public interface IAdminRecoverAccountCommand
/// </summary>
/// <param name="orgId">The organization the user belongs to.</param>
/// <param name="organizationUser">The organization user being recovered.</param>
/// <param name="newMasterPassword">The user's new master password hash.</param>
/// <param name="key">The user's new master-password-sealed user key.</param>
/// <param name="unlockData">The user's new master-password unlock data.</param>
/// <param name="authenticationData">The user's new master-password authentication data.</param>
/// <returns>An IdentityResult indicating success or failure.</returns>
/// <exception cref="BadRequestException">When organization settings, policy, or user state is invalid.</exception>
/// <exception cref="NotFoundException">When the user does not exist.</exception>

View File

@@ -68,6 +68,7 @@ public class AdminRecoverAccountCommand(
return result;
}
}
// To be removed in PM-33141
else
{
var result = await HandlePayloadWithDeprecatedRawDataAsync(user, request);

View File

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

View File

@@ -10,6 +10,7 @@ namespace Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces;
/// </summary>
public interface ITdeOffboardingPasswordCommand
{
[Obsolete("To be removed in PM-33141")]
public Task<IdentityResult> UpdateTdeOffboardingPasswordAsync(User user, string masterPassword, string key,
string? masterPasswordHint);

View File

@@ -46,7 +46,7 @@ public class TdeOffboardingPasswordCommand : ITdeOffboardingPasswordCommand
}
[Obsolete("To be removed in PM-33141")]
public async Task<IdentityResult> UpdateTdeOffboardingPasswordAsync(User user, string newMasterPassword, string key, string hint)
public async Task<IdentityResult> 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

View File

@@ -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<IdentityResult> UpdateTempPasswordAsync(
User user,
MasterPasswordUnlockData unlockData,
MasterPasswordAuthenticationData authenticationData,
string? masterPasswordHint);
}

View File

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

View File

@@ -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<ITdeOffboardingPasswordCommand, TdeOffboardingPasswordCommand>();
}
private static void AddUpdateTempPasswordCommands(this IServiceCollection services)
{
services.AddScoped<IUpdateTempPasswordCommand, UpdateTempPasswordCommand>();
}
private static void AddUserRegistrationCommands(this IServiceCollection services)
{
services.AddScoped<ISendVerificationEmailForRegistrationCommand, SendVerificationEmailForRegistrationCommand>();

View File

@@ -35,6 +35,7 @@ public interface IUserService
Task<IdentityResult> SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier);
Task<IdentityResult> ConvertToKeyConnectorAsync(User user, string keyConnectorKeyWrappedUserKey);
Task<IdentityResult> AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key);
[Obsolete("To be removed in PM-33141")]
Task<IdentityResult> UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint);
Task<IdentityResult> RefreshSecurityStampAsync(User user, string masterPasswordHash);
Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true);

View File

@@ -658,6 +658,7 @@ public class UserService : UserManager<User>, IUserService
return IdentityResult.Success;
}
[Obsolete("To be removed in PM-33141")]
public async Task<IdentityResult> UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint)
{
if (!user.ForcePasswordReset)