diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index a0c01752a..d9dfbafc7 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -148,6 +148,13 @@ public class AccountsController : Controller throw new BadRequestException("MasterPasswordHash", "Invalid password."); } + // If Account Deprovisioning is enabled, we need to check if the user is managed by any organization. + if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + && await _userService.IsManagedByAnyOrganizationAsync(user.Id)) + { + throw new BadRequestException("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details."); + } + await _userService.InitiateEmailChangeAsync(user, model.NewEmail); } @@ -165,6 +172,13 @@ public class AccountsController : Controller throw new BadRequestException("You cannot change your email when using Key Connector."); } + // If Account Deprovisioning is enabled, we need to check if the user is managed by any organization. + if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + && await _userService.IsManagedByAnyOrganizationAsync(user.Id)) + { + throw new BadRequestException("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details."); + } + var result = await _userService.ChangeEmailAsync(user, model.MasterPasswordHash, model.NewEmail, model.NewMasterPasswordHash, model.Token, model.Key); if (result.Succeeded) diff --git a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs index b6a0ccbed..6dd7f42c6 100644 --- a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs @@ -1,6 +1,15 @@ -using System.Net.Http.Headers; +using System.Net; +using System.Net.Http.Headers; +using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Response; +using Bit.Core; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Services; +using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.Controllers; @@ -35,4 +44,82 @@ public class AccountsControllerTest : IClassFixture Assert.Null(content.PrivateKey); Assert.NotNull(content.SecurityStamp); } + + [Fact] + public async Task PostEmailToken_WhenAccountDeprovisioningEnabled_WithManagedAccount_ThrowsBadRequest() + { + var email = await SetupOrganizationManagedAccount(); + + var tokens = await _factory.LoginAsync(email); + var client = _factory.CreateClient(); + + var model = new EmailTokenRequestModel + { + NewEmail = $"{Guid.NewGuid()}@example.com", + MasterPasswordHash = "master_password_hash" + }; + + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/email-token") + { + Content = JsonContent.Create(model) + }; + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); + var response = await client.SendAsync(message); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Cannot change emails for accounts owned by an organization", content); + } + + [Fact] + public async Task PostEmail_WhenAccountDeprovisioningEnabled_WithManagedAccount_ThrowsBadRequest() + { + var email = await SetupOrganizationManagedAccount(); + + var tokens = await _factory.LoginAsync(email); + var client = _factory.CreateClient(); + + var model = new EmailRequestModel + { + NewEmail = $"{Guid.NewGuid()}@example.com", + MasterPasswordHash = "master_password_hash", + NewMasterPasswordHash = "master_password_hash", + Token = "validtoken", + Key = "key" + }; + + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/email") + { + Content = JsonContent.Create(model) + }; + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); + var response = await client.SendAsync(message); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Cannot change emails for accounts owned by an organization", content); + } + + private async Task SetupOrganizationManagedAccount() + { + _factory.SubstituteService(featureService => + featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true)); + + // Create the owner account + var ownerEmail = $"{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ownerEmail); + + // Create the organization + var (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually2023, + ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + // Create a new organization member + var (email, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.Custom, new Permissions { AccessReports = true, ManageScim = true }); + + // Add a verified domain + await OrganizationTestHelpers.CreateVerifiedDomainAsync(_factory, _organization.Id, "bitwarden.com"); + + return email; + } } diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index 83b345e78..64f719e82 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -105,4 +105,22 @@ public static class OrganizationTestHelpers return (email, organizationUser); } + + /// + /// Creates a VerifiedDomain for the specified organization. + /// + public static async Task CreateVerifiedDomainAsync(ApiApplicationFactory factory, Guid organizationId, string domain) + { + var organizationDomainRepository = factory.GetService(); + + var verifiedDomain = new OrganizationDomain + { + OrganizationId = organizationId, + DomainName = domain, + Txt = "btw+test18383838383" + }; + verifiedDomain.SetVerifiedDate(); + + await organizationDomainRepository.CreateAsync(verifiedDomain); + } } diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index a16a9cb55..4127c92ee 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -7,6 +7,7 @@ using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.Auth.Validators; using Bit.Api.Tools.Models.Request; using Bit.Api.Vault.Models.Request; +using Bit.Core; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; @@ -143,6 +144,21 @@ public class AccountsControllerTests : IDisposable await _userService.Received(1).InitiateEmailChangeAsync(user, newEmail); } + [Fact] + public async Task PostEmailToken_WithAccountDeprovisioningEnabled_WhenUserIsNotManagedByAnOrganization_ShouldInitiateEmailChange() + { + var user = GenerateExampleUser(); + ConfigureUserServiceToReturnValidPrincipalFor(user); + ConfigureUserServiceToAcceptPasswordFor(user); + _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); + _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(false); + var newEmail = "example@user.com"; + + await _sut.PostEmailToken(new EmailTokenRequestModel { NewEmail = newEmail }); + + await _userService.Received(1).InitiateEmailChangeAsync(user, newEmail); + } + [Fact] public async Task PostEmailToken_WhenNotAuthorized_ShouldThrowUnauthorizedAccessException() { @@ -165,6 +181,22 @@ public class AccountsControllerTests : IDisposable ); } + [Fact] + public async Task PostEmailToken_WithAccountDeprovisioningEnabled_WhenUserIsManagedByAnOrganization_ShouldThrowBadRequestException() + { + var user = GenerateExampleUser(); + ConfigureUserServiceToReturnValidPrincipalFor(user); + ConfigureUserServiceToAcceptPasswordFor(user); + _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); + _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(true); + + var result = await Assert.ThrowsAsync( + () => _sut.PostEmailToken(new EmailTokenRequestModel()) + ); + + Assert.Equal("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.", result.Message); + } + [Fact] public async Task PostEmail_ShouldChangeUserEmail() { @@ -178,6 +210,21 @@ public class AccountsControllerTests : IDisposable await _userService.Received(1).ChangeEmailAsync(user, default, default, default, default, default); } + [Fact] + public async Task PostEmail_WithAccountDeprovisioningEnabled_WhenUserIsNotManagedByAnOrganization_ShouldChangeUserEmail() + { + var user = GenerateExampleUser(); + ConfigureUserServiceToReturnValidPrincipalFor(user); + _userService.ChangeEmailAsync(user, default, default, default, default, default) + .Returns(Task.FromResult(IdentityResult.Success)); + _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); + _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(false); + + await _sut.PostEmail(new EmailRequestModel()); + + await _userService.Received(1).ChangeEmailAsync(user, default, default, default, default, default); + } + [Fact] public async Task PostEmail_WhenNotAuthorized_ShouldThrownUnauthorizedAccessException() { @@ -201,6 +248,21 @@ public class AccountsControllerTests : IDisposable ); } + [Fact] + public async Task PostEmail_WithAccountDeprovisioningEnabled_WhenUserIsManagedByAnOrganization_ShouldThrowBadRequestException() + { + var user = GenerateExampleUser(); + ConfigureUserServiceToReturnValidPrincipalFor(user); + _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); + _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(true); + + var result = await Assert.ThrowsAsync( + () => _sut.PostEmail(new EmailRequestModel()) + ); + + Assert.Equal("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.", result.Message); + } + [Fact] public async Task PostVerifyEmail_ShouldSendEmailVerification() {