diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 50273e281dea..c1a4b078379e 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -415,6 +415,10 @@ public async Task ChangeEmailAsync(User user, string masterPassw user.Email = newEmail; user.EmailVerified = true; user.RevisionDate = user.AccountRevisionDate = now; + + // We need this to backfill the salt for now to keep the email and salt always in sync. + user.MasterPasswordSalt = newEmail; + user.LastEmailChangeDate = now; await _userRepository.ReplaceAsync(user); @@ -431,6 +435,10 @@ await _stripeSyncService.UpdateCustomerEmailAddressAsync(user.GatewayCustomerId, //if sync to strip fails, update email and securityStamp to previous user.Key = previousState.Key; user.Email = previousState.Email; + + // We need this to backfill the salt for now to keep the email and salt always in sync. + user.MasterPasswordSalt = previousState.Email; + user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow; user.MasterPassword = previousState.MasterPassword; user.SecurityStamp = previousState.SecurityStamp; diff --git a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs index 833f724ae375..24d738ae18a6 100644 --- a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs @@ -9,6 +9,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Api.Request; @@ -51,6 +52,7 @@ public class AccountsControllerTest : IClassFixture, IAsy private readonly IUserSignatureKeyPairRepository _userSignatureKeyPairRepository; private readonly IEventRepository _eventRepository; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IStripeSyncService _stripeSyncService; private string _ownerEmail = null!; @@ -59,6 +61,7 @@ public AccountsControllerTest(ApiApplicationFactory factory) _factory = factory; _factory.SubstituteService(_ => { }); _factory.SubstituteService(_ => { }); + _factory.SubstituteService(_ => { }); _client = factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); _userRepository = _factory.GetService(); @@ -70,6 +73,7 @@ public AccountsControllerTest(ApiApplicationFactory factory) _userSignatureKeyPairRepository = _factory.GetService(); _eventRepository = _factory.GetService(); _organizationUserRepository = _factory.GetService(); + _stripeSyncService = _factory.GetService(); } public async Task InitializeAsync() @@ -949,6 +953,7 @@ public async Task PostEmail_Success_UpdatesEmailAndPassword() Assert.Equal(_masterKeyWrappedUserKey, updatedUser.Key); Assert.Equal(PasswordVerificationResult.Success, _passwordHasher.VerifyHashedPassword(updatedUser, updatedUser.MasterPassword!, _newMasterPasswordHash)); + Assert.Equal(newEmail, updatedUser.MasterPasswordSalt); } [Fact] @@ -986,6 +991,43 @@ public async Task PostEmail_WhenInvalidMasterPassword_ReturnsBadRequest() Assert.NotNull(unchangedUser); } + [Fact] + public async Task PostEmail_WhenStripeSyncFails_MasterPasswordSaltIsRolledBack() + { + // Arrange + var newEmail = $"new-email-{Guid.NewGuid()}@bitwarden.com"; + await _loginHelper.LoginAsync(_ownerEmail); + + var user = await _userRepository.GetByEmailAsync(_ownerEmail); + Assert.NotNull(user); + + // Set up the user as a Stripe customer to exercise the sync code path in ChangeEmailAsync + user.Gateway = GatewayType.Stripe; + user.GatewayCustomerId = "cus_test_stripe_fail"; + await _userRepository.ReplaceAsync(user); + + // Configure the substitute to simulate a Stripe sync failure after the DB write + _stripeSyncService + .UpdateCustomerEmailAddressAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new Exception("Stripe sync failure"))); + + var userManager = _factory.GetService>(); + var token = await userManager.GenerateChangeEmailTokenAsync(user, newEmail); + + // Act + var response = await PostEmailAsync(newEmail, token); + + // Assert - Stripe failure is surfaced as a bad request + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + // Verify MasterPasswordSalt was rolled back to the original email, not left at newEmail. + // ChangeEmailAsync sets MasterPasswordSalt = newEmail and persists before attempting Stripe + // sync; on failure it must re-persist MasterPasswordSalt = previousState.Email. + var unchangedUser = await _userRepository.GetByEmailAsync(_ownerEmail); + Assert.NotNull(unchangedUser); + Assert.Equal(_ownerEmail, unchangedUser.MasterPasswordSalt); + } + private async Task PostEmailAsync(string newEmail, string token) { var requestModel = new EmailRequestModel