From c0700a69465c95df02b106631c3f2e1e2b99522f Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Wed, 19 Nov 2025 20:25:50 -0500 Subject: [PATCH] [PM-27766] Add policy for blocking account creation from claimed domains. (#6537) * Add policy for blocking account creation from claimed domains. * dotnet format * check as part of email verification * add feature flag * fix tests * try to fix dates on database integration tests * PR feedback from claude * remove claude local settings * pr feedback * format * fix test * create or alter * PR feedback * PR feedback * Update src/Core/Constants.cs Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> * fix merge issues * fix tests --------- Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> --- src/Core/AdminConsole/Enums/PolicyType.cs | 2 + .../PolicyServiceCollectionExtensions.cs | 1 + ...medDomainAccountCreationPolicyValidator.cs | 59 +++ .../Implementations/RegisterUserCommand.cs | 48 +- ...VerificationEmailForRegistrationCommand.cs | 25 +- src/Core/Constants.cs | 1 + .../IOrganizationDomainRepository.cs | 1 + src/Core/Utilities/EmailValidation.cs | 22 +- .../OrganizationDomainRepository.cs | 12 + .../OrganizationDomainRepository.cs | 20 + ...omain_HasVerifiedDomainWithBlockPolicy.sql | 34 ++ ...mainAccountCreationPolicyValidatorTests.cs | 189 ++++++++ .../Registration/RegisterUserCommandTests.cs | 438 +++++++++++++++++ ...icationEmailForRegistrationCommandTests.cs | 119 ++++- .../Utilities/EmailValidationTests.cs | 51 ++ .../Controllers/AccountsControllerTests.cs | 8 +- .../OrganizationDomainRepositoryTests.cs | 449 +++++++++++++++++- ...lockClaimedDomainAccountCreationPolicy.sql | 41 ++ 18 files changed, 1502 insertions(+), 18 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidator.cs create mode 100644 src/Sql/dbo/Stored Procedures/OrganizationDomain_HasVerifiedDomainWithBlockPolicy.sql create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidatorTests.cs create mode 100644 test/Core.Test/Utilities/EmailValidationTests.cs create mode 100644 util/Migrator/DbScripts/2025-11-04_00_BlockClaimedDomainAccountCreationPolicy.sql diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index 09fa4ec955..bd6daf7cdf 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -21,6 +21,7 @@ public enum PolicyType : byte UriMatchDefaults = 16, AutotypeDefaultSetting = 17, AutomaticUserConfirmation = 18, + BlockClaimedDomainAccountCreation = 19, } public static class PolicyTypeExtensions @@ -52,6 +53,7 @@ public static class PolicyTypeExtensions PolicyType.UriMatchDefaults => "URI match defaults", PolicyType.AutotypeDefaultSetting => "Autotype default setting", PolicyType.AutomaticUserConfirmation => "Automatically confirm invited users", + PolicyType.BlockClaimedDomainAccountCreation => "Block account creation for claimed domains", }; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index e0ca8d6f90..e89592f020 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -53,6 +53,7 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidator.cs new file mode 100644 index 0000000000..92ba11f5a6 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidator.cs @@ -0,0 +1,59 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class BlockClaimedDomainAccountCreationPolicyValidator : IPolicyValidator, IPolicyValidationEvent +{ + private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; + private readonly IFeatureService _featureService; + + public BlockClaimedDomainAccountCreationPolicyValidator( + IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, + IFeatureService featureService) + { + _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; + _featureService = featureService; + } + + public PolicyType Type => PolicyType.BlockClaimedDomainAccountCreation; + + // No prerequisites - this policy stands alone + public IEnumerable RequiredPolicies => []; + + public async Task ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy) + { + return await ValidateAsync(policyRequest.PolicyUpdate, currentPolicy); + } + + public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + // Check if feature is enabled + if (!_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)) + { + return "This feature is not enabled"; + } + + // Only validate when trying to ENABLE the policy + if (policyUpdate is { Enabled: true }) + { + // Check if organization has at least one verified domain + if (!await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)) + { + return "You must claim at least one domain to turn on this policy"; + } + } + + // Disabling the policy is always allowed + return string.Empty; + } + + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + => Task.CompletedTask; +} diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 4aaa9360a0..baeb24368e 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -15,16 +15,20 @@ using Bit.Core.Tokens; using Bit.Core.Utilities; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace Bit.Core.Auth.UserFeatures.Registration.Implementations; public class RegisterUserCommand : IRegisterUserCommand { + private readonly ILogger _logger; private readonly IGlobalSettings _globalSettings; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IPolicyRepository _policyRepository; + private readonly IOrganizationDomainRepository _organizationDomainRepository; + private readonly IFeatureService _featureService; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; @@ -37,28 +41,32 @@ public class RegisterUserCommand : IRegisterUserCommand private readonly IValidateRedemptionTokenCommand _validateRedemptionTokenCommand; private readonly IDataProtectorTokenFactory _emergencyAccessInviteTokenDataFactory; - private readonly IFeatureService _featureService; private readonly string _disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator."; public RegisterUserCommand( + ILogger logger, IGlobalSettings globalSettings, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, IPolicyRepository policyRepository, + IOrganizationDomainRepository organizationDomainRepository, + IFeatureService featureService, IDataProtectionProvider dataProtectionProvider, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, IUserService userService, IMailService mailService, IValidateRedemptionTokenCommand validateRedemptionTokenCommand, - IDataProtectorTokenFactory emergencyAccessInviteTokenDataFactory, - IFeatureService featureService) + IDataProtectorTokenFactory emergencyAccessInviteTokenDataFactory) { + _logger = logger; _globalSettings = globalSettings; _organizationUserRepository = organizationUserRepository; _organizationRepository = organizationRepository; _policyRepository = policyRepository; + _organizationDomainRepository = organizationDomainRepository; + _featureService = featureService; _organizationServiceDataProtector = dataProtectionProvider.CreateProtector( "OrganizationServiceDataProtector"); @@ -77,6 +85,8 @@ public class RegisterUserCommand : IRegisterUserCommand public async Task RegisterUser(User user) { + await ValidateEmailDomainNotBlockedAsync(user.Email); + var result = await _userService.CreateUserAsync(user); if (result == IdentityResult.Success) { @@ -102,6 +112,11 @@ public class RegisterUserCommand : IRegisterUserCommand { TryValidateOrgInviteToken(orgInviteToken, orgUserId, user); var orgUser = await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user); + if (orgUser == null && orgUserId.HasValue) + { + throw new BadRequestException("Invalid organization user invitation."); + } + await ValidateEmailDomainNotBlockedAsync(user.Email, orgUser?.OrganizationId); user.ApiKey = CoreHelpers.SecureRandomString(30); @@ -265,6 +280,8 @@ public class RegisterUserCommand : IRegisterUserCommand string emailVerificationToken) { ValidateOpenRegistrationAllowed(); + await ValidateEmailDomainNotBlockedAsync(user.Email); + var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email); user.EmailVerified = true; @@ -284,6 +301,7 @@ public class RegisterUserCommand : IRegisterUserCommand string orgSponsoredFreeFamilyPlanInviteToken) { ValidateOpenRegistrationAllowed(); + await ValidateEmailDomainNotBlockedAsync(user.Email); await ValidateOrgSponsoredFreeFamilyPlanInviteToken(orgSponsoredFreeFamilyPlanInviteToken, user.Email); user.EmailVerified = true; @@ -304,6 +322,7 @@ public class RegisterUserCommand : IRegisterUserCommand string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { ValidateOpenRegistrationAllowed(); + await ValidateEmailDomainNotBlockedAsync(user.Email); ValidateAcceptEmergencyAccessInviteToken(acceptEmergencyAccessInviteToken, acceptEmergencyAccessId, user.Email); user.EmailVerified = true; @@ -322,6 +341,7 @@ public class RegisterUserCommand : IRegisterUserCommand string providerInviteToken, Guid providerUserId) { ValidateOpenRegistrationAllowed(); + await ValidateEmailDomainNotBlockedAsync(user.Email); ValidateProviderInviteToken(providerInviteToken, providerUserId, user.Email); user.EmailVerified = true; @@ -387,6 +407,28 @@ public class RegisterUserCommand : IRegisterUserCommand return tokenable; } + private async Task ValidateEmailDomainNotBlockedAsync(string email, Guid? excludeOrganizationId = null) + { + // Only check if feature flag is enabled + if (!_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)) + { + return; + } + + var emailDomain = EmailValidation.GetDomain(email); + + var isDomainBlocked = await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync( + emailDomain, excludeOrganizationId); + if (isDomainBlocked) + { + _logger.LogInformation( + "User registration blocked by domain claim policy. Domain: {Domain}, ExcludedOrgId: {ExcludedOrgId}", + emailDomain, + excludeOrganizationId); + throw new BadRequestException("This email address is claimed by an organization using Bitwarden."); + } + } + /// /// We send different welcome emails depending on whether the user is joining a free/family or an enterprise organization. If information to populate the /// email isn't present we send the standard individual welcome email. diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs index 3f89e9ad0e..5841cd2e62 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs @@ -5,6 +5,8 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; namespace Bit.Core.Auth.UserFeatures.Registration.Implementations; @@ -15,25 +17,30 @@ namespace Bit.Core.Auth.UserFeatures.Registration.Implementations; /// public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmailForRegistrationCommand { - + private readonly ILogger _logger; private readonly IUserRepository _userRepository; private readonly GlobalSettings _globalSettings; private readonly IMailService _mailService; private readonly IDataProtectorTokenFactory _tokenDataFactory; private readonly IFeatureService _featureService; + private readonly IOrganizationDomainRepository _organizationDomainRepository; public SendVerificationEmailForRegistrationCommand( + ILogger logger, IUserRepository userRepository, GlobalSettings globalSettings, IMailService mailService, IDataProtectorTokenFactory tokenDataFactory, - IFeatureService featureService) + IFeatureService featureService, + IOrganizationDomainRepository organizationDomainRepository) { + _logger = logger; _userRepository = userRepository; _globalSettings = globalSettings; _mailService = mailService; _tokenDataFactory = tokenDataFactory; _featureService = featureService; + _organizationDomainRepository = organizationDomainRepository; } @@ -49,6 +56,20 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai throw new ArgumentNullException(nameof(email)); } + // Check if the email domain is blocked by an organization policy + if (_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)) + { + var emailDomain = EmailValidation.GetDomain(email); + + if (await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(emailDomain)) + { + _logger.LogInformation( + "User registration email verification blocked by domain claim policy. Domain: {Domain}", + emailDomain); + throw new BadRequestException("This email address is claimed by an organization using Bitwarden."); + } + } + // Check to see if the user already exists var user = await _userRepository.GetByEmailAsync(email); var userExists = user != null; diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3f7eb4c786..c75c9ab1fe 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -141,6 +141,7 @@ public static class FeatureFlagKeys public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string AccountRecoveryCommand = "pm-25581-prevent-provider-account-recovery"; + public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration"; public const string PolicyValidatorsRefactor = "pm-26423-refactor-policy-side-effects"; /* Architecture */ diff --git a/src/Core/Repositories/IOrganizationDomainRepository.cs b/src/Core/Repositories/IOrganizationDomainRepository.cs index d802fe65df..b993cd42fa 100644 --- a/src/Core/Repositories/IOrganizationDomainRepository.cs +++ b/src/Core/Repositories/IOrganizationDomainRepository.cs @@ -17,4 +17,5 @@ public interface IOrganizationDomainRepository : IRepository GetDomainByOrgIdAndDomainNameAsync(Guid orgId, string domainName); Task> GetExpiredOrganizationDomainsAsync(); Task DeleteExpiredAsync(int expirationPeriod); + Task HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(string domainName, Guid? excludeOrganizationId = null); } diff --git a/src/Core/Utilities/EmailValidation.cs b/src/Core/Utilities/EmailValidation.cs index f6832945af..10892f85c4 100644 --- a/src/Core/Utilities/EmailValidation.cs +++ b/src/Core/Utilities/EmailValidation.cs @@ -1,4 +1,6 @@ -using System.Text.RegularExpressions; +using System.Net.Mail; +using System.Text.RegularExpressions; +using Bit.Core.Exceptions; using MimeKit; namespace Bit.Core.Utilities; @@ -41,4 +43,22 @@ public static class EmailValidation return true; } + + /// + /// Extracts the domain portion from an email address and normalizes it to lowercase. + /// + /// The email address to extract the domain from. + /// The domain portion of the email address in lowercase (e.g., "example.com"). + /// Thrown when the email address format is invalid. + public static string GetDomain(string email) + { + try + { + return new MailAddress(email).Host.ToLower(); + } + catch (Exception ex) when (ex is FormatException || ex is ArgumentException) + { + throw new BadRequestException("Invalid email address format."); + } + } } diff --git a/src/Infrastructure.Dapper/Repositories/OrganizationDomainRepository.cs b/src/Infrastructure.Dapper/Repositories/OrganizationDomainRepository.cs index 91cbc40ff6..a8171c286b 100644 --- a/src/Infrastructure.Dapper/Repositories/OrganizationDomainRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/OrganizationDomainRepository.cs @@ -148,4 +148,16 @@ public class OrganizationDomainRepository : Repository commandType: CommandType.StoredProcedure) > 0; } } + + public async Task HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(string domainName, Guid? excludeOrganizationId = null) + { + await using var connection = new SqlConnection(ConnectionString); + + var result = await connection.QueryFirstOrDefaultAsync( + $"[{Schema}].[OrganizationDomain_HasVerifiedDomainWithBlockPolicy]", + new { DomainName = domainName, ExcludeOrganizationId = excludeOrganizationId }, + commandType: CommandType.StoredProcedure); + + return result; + } } diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs index 0ddf80130e..d337a5e856 100644 --- a/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs @@ -177,5 +177,25 @@ public class OrganizationDomainRepository : Repository>(verifiedDomains); } + public async Task HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(string domainName, Guid? excludeOrganizationId = null) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var query = from od in dbContext.OrganizationDomains + join o in dbContext.Organizations on od.OrganizationId equals o.Id + join p in dbContext.Policies on o.Id equals p.OrganizationId + where od.DomainName == domainName + && od.VerifiedDate != null + && o.Enabled + && o.UsePolicies + && o.UseOrganizationDomains + && (!excludeOrganizationId.HasValue || o.Id != excludeOrganizationId.Value) + && p.Type == Core.AdminConsole.Enums.PolicyType.BlockClaimedDomainAccountCreation + && p.Enabled + select od; + + return await query.AnyAsync(); + } } diff --git a/src/Sql/dbo/Stored Procedures/OrganizationDomain_HasVerifiedDomainWithBlockPolicy.sql b/src/Sql/dbo/Stored Procedures/OrganizationDomain_HasVerifiedDomainWithBlockPolicy.sql new file mode 100644 index 0000000000..bfa9d932c5 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationDomain_HasVerifiedDomainWithBlockPolicy.sql @@ -0,0 +1,34 @@ +CREATE PROCEDURE [dbo].[OrganizationDomain_HasVerifiedDomainWithBlockPolicy] + @DomainName NVARCHAR(255), + @ExcludeOrganizationId UNIQUEIDENTIFIER = NULL +AS +BEGIN + SET NOCOUNT ON + + -- Check if any organization has a verified domain matching the domain name + -- with the BlockClaimedDomainAccountCreation policy enabled (Type = 19) + -- If @ExcludeOrganizationId is provided, exclude that organization from the check + IF EXISTS ( + SELECT 1 + FROM [dbo].[OrganizationDomain] OD + INNER JOIN [dbo].[Organization] O + ON OD.OrganizationId = O.Id + INNER JOIN [dbo].[Policy] P + ON O.Id = P.OrganizationId + WHERE OD.DomainName = @DomainName + AND OD.VerifiedDate IS NOT NULL + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND O.UseOrganizationDomains = 1 + AND (@ExcludeOrganizationId IS NULL OR O.Id != @ExcludeOrganizationId) + AND P.Type = 19 -- BlockClaimedDomainAccountCreation + AND P.Enabled = 1 + ) + BEGIN + SELECT CAST(1 AS BIT) AS HasBlockPolicy + END + ELSE + BEGIN + SELECT CAST(0 AS BIT) AS HasBlockPolicy + END +END diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidatorTests.cs new file mode 100644 index 0000000000..e317a5886e --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidatorTests.cs @@ -0,0 +1,189 @@ +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +[SutProviderCustomize] +public class BlockClaimedDomainAccountCreationPolicyValidatorTests +{ + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_NoVerifiedDomains_ValidationError( + [PolicyUpdate(PolicyType.BlockClaimedDomainAccountCreation, true)] PolicyUpdate policyUpdate, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainsAsync(policyUpdate.OrganizationId) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Equal("You must claim at least one domain to turn on this policy", result); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_HasVerifiedDomains_Success( + [PolicyUpdate(PolicyType.BlockClaimedDomainAccountCreation, true)] PolicyUpdate policyUpdate, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainsAsync(policyUpdate.OrganizationId) + .Returns(true); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_NoValidation( + [PolicyUpdate(PolicyType.BlockClaimedDomainAccountCreation, false)] PolicyUpdate policyUpdate, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + await sutProvider.GetDependency() + .DidNotReceive() + .HasVerifiedDomainsAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_WithSavePolicyModel_EnablingPolicy_NoVerifiedDomains_ValidationError( + [PolicyUpdate(PolicyType.BlockClaimedDomainAccountCreation, true)] PolicyUpdate policyUpdate, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainsAsync(policyUpdate.OrganizationId) + .Returns(false); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + // Act + var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null); + + // Assert + Assert.Equal("You must claim at least one domain to turn on this policy", result); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_WithSavePolicyModel_EnablingPolicy_HasVerifiedDomains_Success( + [PolicyUpdate(PolicyType.BlockClaimedDomainAccountCreation, true)] PolicyUpdate policyUpdate, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainsAsync(policyUpdate.OrganizationId) + .Returns(true); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + // Act + var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_NoValidation( + [PolicyUpdate(PolicyType.BlockClaimedDomainAccountCreation, false)] PolicyUpdate policyUpdate, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + // Act + var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + await sutProvider.GetDependency() + .DidNotReceive() + .HasVerifiedDomainsAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_FeatureFlagDisabled_ReturnsError( + [PolicyUpdate(PolicyType.BlockClaimedDomainAccountCreation, true)] PolicyUpdate policyUpdate, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Equal("This feature is not enabled", result); + await sutProvider.GetDependency() + .DidNotReceive() + .HasVerifiedDomainsAsync(Arg.Any()); + } + + [Fact] + public void Type_ReturnsBlockClaimedDomainAccountCreation() + { + // Arrange + var validator = new BlockClaimedDomainAccountCreationPolicyValidator(null, null); + + // Act & Assert + Assert.Equal(PolicyType.BlockClaimedDomainAccountCreation, validator.Type); + } + + [Fact] + public void RequiredPolicies_ReturnsEmpty() + { + // Arrange + var validator = new BlockClaimedDomainAccountCreationPolicyValidator(null, null); + + // Act + var requiredPolicies = validator.RequiredPolicies.ToList(); + + // Assert + Assert.Empty(requiredPolicies); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index 16a48b12e3..f40eea636c 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -38,6 +38,12 @@ public class RegisterUserCommandTests public async Task RegisterUser_Succeeds(SutProvider sutProvider, User user) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .CreateUserAsync(user) .Returns(IdentityResult.Success); @@ -62,6 +68,12 @@ public class RegisterUserCommandTests public async Task RegisterUser_WhenCreateUserFails_ReturnsIdentityResultFailed(SutProvider sutProvider, User user) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .CreateUserAsync(user) .Returns(IdentityResult.Failed()); @@ -416,6 +428,138 @@ public class RegisterUserCommandTests Assert.Equal(expectedErrorMessage, exception.Message); } + [Theory] + [BitAutoData] + public async Task RegisterUserViaOrganizationInviteToken_BlockedDomainFromDifferentOrg_ThrowsBadRequestException( + SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId) + { + // Arrange + user.Email = "user@blocked-domain.com"; + orgUser.Email = user.Email; + orgUser.Id = orgUserId; + var blockingOrganizationId = Guid.NewGuid(); // Different org that has the domain blocked + orgUser.OrganizationId = Guid.NewGuid(); // The org they're trying to join + + var orgInviteTokenable = new OrgUserInviteTokenable(orgUser); + + sutProvider.GetDependency>() + .TryUnprotect(orgInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = orgInviteTokenable; + return true; + }); + + sutProvider.GetDependency() + .GetByIdAsync(orgUserId) + .Returns(orgUser); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + // Mock the new overload that excludes the organization - it should return true (domain IS blocked by another org) + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com", orgUser.OrganizationId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId)); + Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaOrganizationInviteToken_BlockedDomainFromSameOrg_Succeeds( + SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId) + { + // Arrange + user.Email = "user@company-domain.com"; + user.ReferenceData = null; + orgUser.Email = user.Email; + orgUser.Id = orgUserId; + // The organization owns the domain and is trying to invite the user + orgUser.OrganizationId = Guid.NewGuid(); + + var orgInviteTokenable = new OrgUserInviteTokenable(orgUser); + + sutProvider.GetDependency>() + .TryUnprotect(orgInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = orgInviteTokenable; + return true; + }); + + sutProvider.GetDependency() + .GetByIdAsync(orgUserId) + .Returns(orgUser); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + // Mock the new overload - it should return false (domain is NOT blocked by OTHER orgs) + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("company-domain.com", orgUser.OrganizationId) + .Returns(false); + + sutProvider.GetDependency() + .CreateUserAsync(user, masterPasswordHash) + .Returns(IdentityResult.Success); + + // Act + var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId); + + // Assert + Assert.True(result.Succeeded); + await sutProvider.GetDependency() + .Received(1) + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("company-domain.com", orgUser.OrganizationId); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaOrganizationInviteToken_WithValidTokenButNullOrgUser_ThrowsBadRequestException( + SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId) + { + // Arrange + user.Email = "user@example.com"; + orgUser.Email = user.Email; + orgUser.Id = orgUserId; + + var orgInviteTokenable = new OrgUserInviteTokenable(orgUser); + + sutProvider.GetDependency>() + .TryUnprotect(orgInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = orgInviteTokenable; + return true; + }); + + // Mock GetByIdAsync to return null - simulating a deleted or non-existent organization user + sutProvider.GetDependency() + .GetByIdAsync(orgUserId) + .Returns((OrganizationUser)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId)); + Assert.Equal("Invalid organization user invitation.", exception.Message); + + // Verify that GetByIdAsync was called + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(orgUserId); + + // Verify that user creation was never attempted + await sutProvider.GetDependency() + .DidNotReceive() + .CreateUserAsync(Arg.Any(), Arg.Any()); + } + // ----------------------------------------------------------------------------------------------- // RegisterUserViaEmailVerificationToken tests // ----------------------------------------------------------------------------------------------- @@ -425,6 +569,12 @@ public class RegisterUserCommandTests public async Task RegisterUserViaEmailVerificationToken_Succeeds(SutProvider sutProvider, User user, string masterPasswordHash, string emailVerificationToken, bool receiveMarketingMaterials) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency>() .TryUnprotect(emailVerificationToken, out Arg.Any()) .Returns(callInfo => @@ -457,6 +607,12 @@ public class RegisterUserCommandTests public async Task RegisterUserViaEmailVerificationToken_InvalidToken_ThrowsBadRequestException(SutProvider sutProvider, User user, string masterPasswordHash, string emailVerificationToken, bool receiveMarketingMaterials) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency>() .TryUnprotect(emailVerificationToken, out Arg.Any()) .Returns(callInfo => @@ -495,6 +651,12 @@ public class RegisterUserCommandTests string orgSponsoredFreeFamilyPlanInviteToken) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .ValidateRedemptionTokenAsync(orgSponsoredFreeFamilyPlanInviteToken, user.Email) .Returns((true, new OrganizationSponsorship())); @@ -524,6 +686,12 @@ public class RegisterUserCommandTests string masterPasswordHash, string orgSponsoredFreeFamilyPlanInviteToken) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .ValidateRedemptionTokenAsync(orgSponsoredFreeFamilyPlanInviteToken, user.Email) .Returns((false, new OrganizationSponsorship())); @@ -561,9 +729,14 @@ public class RegisterUserCommandTests EmergencyAccess emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; emergencyAccess.Email = user.Email; emergencyAccess.Id = acceptEmergencyAccessId; + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency>() .TryUnprotect(acceptEmergencyAccessInviteToken, out Arg.Any()) .Returns(callInfo => @@ -597,9 +770,14 @@ public class RegisterUserCommandTests string masterPasswordHash, EmergencyAccess emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; emergencyAccess.Email = "wrong@email.com"; emergencyAccess.Id = acceptEmergencyAccessId; + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency>() .TryUnprotect(acceptEmergencyAccessInviteToken, out Arg.Any()) .Returns(callInfo => @@ -640,6 +818,8 @@ public class RegisterUserCommandTests User user, string masterPasswordHash, Guid providerUserId) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + // Start with plaintext var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow); var decryptedProviderInviteToken = $"ProviderUserInvite {providerUserId} {user.Email} {nowMillis}"; @@ -662,6 +842,10 @@ public class RegisterUserCommandTests sutProvider.GetDependency() .OrganizationInviteExpirationHours.Returns(120); // 5 days + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .CreateUserAsync(user, masterPasswordHash) .Returns(IdentityResult.Success); @@ -691,6 +875,8 @@ public class RegisterUserCommandTests User user, string masterPasswordHash, Guid providerUserId) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + // Start with plaintext var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow); var decryptedProviderInviteToken = $"ProviderUserInvite {providerUserId} {user.Email} {nowMillis}"; @@ -713,6 +899,10 @@ public class RegisterUserCommandTests sutProvider.GetDependency() .OrganizationInviteExpirationHours.Returns(120); // 5 days + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + // Using sutProvider in the parameters of the function means that the constructor has already run for the // command so we have to recreate it in order for our mock overrides to be used. sutProvider.Create(); @@ -762,6 +952,66 @@ public class RegisterUserCommandTests } // ----------------------------------------------------------------------------------------------- + // Domain blocking tests (BlockClaimedDomainAccountCreation policy) + // ----------------------------------------------------------------------------------------------- + + [Theory] + [BitAutoData] + public async Task RegisterUser_BlockedDomain_ThrowsBadRequestException( + SutProvider sutProvider, User user) + { + // Arrange + user.Email = "user@blocked-domain.com"; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com") + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUser(user)); + Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); + + // Verify user creation was never attempted + await sutProvider.GetDependency() + .DidNotReceive() + .CreateUserAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task RegisterUser_AllowedDomain_Succeeds( + SutProvider sutProvider, User user) + { + // Arrange + user.Email = "user@allowed-domain.com"; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("allowed-domain.com") + .Returns(false); + + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + // Act + var result = await sutProvider.Sut.RegisterUser(user); + + // Assert + Assert.True(result.Succeeded); + await sutProvider.GetDependency() + .Received(1) + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("allowed-domain.com"); + } + // SendWelcomeEmail tests // ----------------------------------------------------------------------------------------------- [Theory] @@ -799,6 +1049,194 @@ public class RegisterUserCommandTests .SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.Name); } + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationToken_BlockedDomain_ThrowsBadRequestException( + SutProvider sutProvider, User user, string masterPasswordHash, + string emailVerificationToken, bool receiveMarketingMaterials) + { + // Arrange + user.Email = "user@blocked-domain.com"; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com") + .Returns(true); + + sutProvider.GetDependency>() + .TryUnprotect(emailVerificationToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new RegistrationEmailVerificationTokenable(user.Email, user.Name, receiveMarketingMaterials); + return true; + }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, masterPasswordHash, emailVerificationToken)); + Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken_BlockedDomain_ThrowsBadRequestException( + SutProvider sutProvider, User user, string masterPasswordHash, + string orgSponsoredFreeFamilyPlanInviteToken) + { + // Arrange + user.Email = "user@blocked-domain.com"; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com") + .Returns(true); + + sutProvider.GetDependency() + .ValidateRedemptionTokenAsync(orgSponsoredFreeFamilyPlanInviteToken, user.Email) + .Returns((true, new OrganizationSponsorship())); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, masterPasswordHash, orgSponsoredFreeFamilyPlanInviteToken)); + Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_BlockedDomain_ThrowsBadRequestException( + SutProvider sutProvider, User user, string masterPasswordHash, + EmergencyAccess emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) + { + // Arrange + user.Email = "user@blocked-domain.com"; + emergencyAccess.Email = user.Email; + emergencyAccess.Id = acceptEmergencyAccessId; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com") + .Returns(true); + + sutProvider.GetDependency>() + .TryUnprotect(acceptEmergencyAccessInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new EmergencyAccessInviteTokenable(emergencyAccess, 10); + return true; + }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaAcceptEmergencyAccessInviteToken(user, masterPasswordHash, acceptEmergencyAccessInviteToken, acceptEmergencyAccessId)); + Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaProviderInviteToken_BlockedDomain_ThrowsBadRequestException( + SutProvider sutProvider, User user, string masterPasswordHash, Guid providerUserId) + { + // Arrange + user.Email = "user@blocked-domain.com"; + + // Start with plaintext + var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow); + var decryptedProviderInviteToken = $"ProviderUserInvite {providerUserId} {user.Email} {nowMillis}"; + + // Get the byte array of the plaintext + var decryptedProviderInviteTokenByteArray = Encoding.UTF8.GetBytes(decryptedProviderInviteToken); + + // Base64 encode the byte array (this is passed to protector.protect(bytes)) + var base64EncodedProviderInvToken = WebEncoders.Base64UrlEncode(decryptedProviderInviteTokenByteArray); + + var mockDataProtector = Substitute.For(); + + // Given any byte array, just return the decryptedProviderInviteTokenByteArray (sidestepping any actual encryption) + mockDataProtector.Unprotect(Arg.Any()).Returns(decryptedProviderInviteTokenByteArray); + + sutProvider.GetDependency() + .CreateProtector("ProviderServiceDataProtector") + .Returns(mockDataProtector); + + sutProvider.GetDependency() + .OrganizationInviteExpirationHours.Returns(120); // 5 days + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com") + .Returns(true); + + // Using sutProvider in the parameters of the function means that the constructor has already run for the + // command so we have to recreate it in order for our mock overrides to be used. + sutProvider.Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaProviderInviteToken(user, masterPasswordHash, base64EncodedProviderInvToken, providerUserId)); + Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); + } + + // ----------------------------------------------------------------------------------------------- + // Invalid email format tests + // ----------------------------------------------------------------------------------------------- + + [Theory] + [BitAutoData] + public async Task RegisterUser_InvalidEmailFormat_ThrowsBadRequestException( + SutProvider sutProvider, User user) + { + // Arrange + user.Email = "invalid-email-format"; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUser(user)); + Assert.Equal("Invalid email address format.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationToken_InvalidEmailFormat_ThrowsBadRequestException( + SutProvider sutProvider, User user, string masterPasswordHash, + string emailVerificationToken, bool receiveMarketingMaterials) + { + // Arrange + user.Email = "invalid-email-format"; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency>() + .TryUnprotect(emailVerificationToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new RegistrationEmailVerificationTokenable(user.Email, user.Name, receiveMarketingMaterials); + return true; + }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, masterPasswordHash, emailVerificationToken)); + Assert.Equal("Invalid email address format.", exception.Message); + } + [Theory] [BitAutoData] public async Task SendWelcomeEmail_OrganizationNull_SendsIndividualWelcomeEmail( diff --git a/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs index f4f620f8a9..bb4bce08c1 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs @@ -21,9 +21,11 @@ public class SendVerificationEmailForRegistrationCommandTests [Theory] [BitAutoData] public async Task SendVerificationEmailForRegistrationCommand_WhenIsNewUserAndEnableEmailVerificationTrue_SendsEmailAndReturnsNull(SutProvider sutProvider, - string email, string name, bool receiveMarketingEmails) + string name, bool receiveMarketingEmails) { // Arrange + var email = $"test+{Guid.NewGuid()}@example.com"; + sutProvider.GetDependency() .GetByEmailAsync(email) .ReturnsNull(); @@ -34,6 +36,10 @@ public class SendVerificationEmailForRegistrationCommandTests sutProvider.GetDependency() .DisableUserRegistration = false; + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .SendRegistrationVerificationEmailAsync(email, Arg.Any()) .Returns(Task.CompletedTask); @@ -56,9 +62,11 @@ public class SendVerificationEmailForRegistrationCommandTests [Theory] [BitAutoData] public async Task SendVerificationEmailForRegistrationCommand_WhenIsExistingUserAndEnableEmailVerificationTrue_ReturnsNull(SutProvider sutProvider, - string email, string name, bool receiveMarketingEmails) + string name, bool receiveMarketingEmails) { // Arrange + var email = $"test+{Guid.NewGuid()}@example.com"; + sutProvider.GetDependency() .GetByEmailAsync(email) .Returns(new User()); @@ -69,6 +77,10 @@ public class SendVerificationEmailForRegistrationCommandTests sutProvider.GetDependency() .DisableUserRegistration = false; + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + var mockedToken = "token"; sutProvider.GetDependency>() .Protect(Arg.Any()) @@ -87,9 +99,11 @@ public class SendVerificationEmailForRegistrationCommandTests [Theory] [BitAutoData] public async Task SendVerificationEmailForRegistrationCommand_WhenIsNewUserAndEnableEmailVerificationFalse_ReturnsToken(SutProvider sutProvider, - string email, string name, bool receiveMarketingEmails) + string name, bool receiveMarketingEmails) { // Arrange + var email = $"test+{Guid.NewGuid()}@example.com"; + sutProvider.GetDependency() .GetByEmailAsync(email) .ReturnsNull(); @@ -100,6 +114,10 @@ public class SendVerificationEmailForRegistrationCommandTests sutProvider.GetDependency() .DisableUserRegistration = false; + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + var mockedToken = "token"; sutProvider.GetDependency>() .Protect(Arg.Any()) @@ -128,9 +146,11 @@ public class SendVerificationEmailForRegistrationCommandTests [Theory] [BitAutoData] public async Task SendVerificationEmailForRegistrationCommand_WhenIsExistingUserAndEnableEmailVerificationFalse_ThrowsBadRequestException(SutProvider sutProvider, - string email, string name, bool receiveMarketingEmails) + string name, bool receiveMarketingEmails) { // Arrange + var email = $"test+{Guid.NewGuid()}@example.com"; + sutProvider.GetDependency() .GetByEmailAsync(email) .Returns(new User()); @@ -138,6 +158,13 @@ public class SendVerificationEmailForRegistrationCommandTests sutProvider.GetDependency() .EnableEmailVerification = false; + sutProvider.GetDependency() + .DisableUserRegistration = false; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + // Act & Assert await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails)); } @@ -162,4 +189,88 @@ public class SendVerificationEmailForRegistrationCommandTests .DisableUserRegistration = false; await Assert.ThrowsAsync(async () => await sutProvider.Sut.Run("", name, receiveMarketingEmails)); } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_WhenBlockedDomain_ThrowsBadRequestException(SutProvider sutProvider, + string name, bool receiveMarketingEmails) + { + // Arrange + var email = $"test+{Guid.NewGuid()}@blockedcompany.com"; + + sutProvider.GetDependency() + .DisableUserRegistration = false; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blockedcompany.com") + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails)); + Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_WhenAllowedDomain_Succeeds(SutProvider sutProvider, + string name, bool receiveMarketingEmails) + { + // Arrange + var email = $"test+{Guid.NewGuid()}@allowedcompany.com"; + + sutProvider.GetDependency() + .GetByEmailAsync(email) + .ReturnsNull(); + + sutProvider.GetDependency() + .EnableEmailVerification = false; + + sutProvider.GetDependency() + .DisableUserRegistration = false; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("allowedcompany.com") + .Returns(false); + + var mockedToken = "token"; + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns(mockedToken); + + // Act + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails); + + // Assert + Assert.Equal(mockedToken, result); + } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_InvalidEmailFormat_ThrowsBadRequestException( + SutProvider sutProvider, + string name, bool receiveMarketingEmails) + { + // Arrange + var email = "invalid-email-format"; + + sutProvider.GetDependency() + .DisableUserRegistration = false; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.Run(email, name, receiveMarketingEmails)); + Assert.Equal("Invalid email address format.", exception.Message); + } } diff --git a/test/Core.Test/Utilities/EmailValidationTests.cs b/test/Core.Test/Utilities/EmailValidationTests.cs new file mode 100644 index 0000000000..ac59f5f44a --- /dev/null +++ b/test/Core.Test/Utilities/EmailValidationTests.cs @@ -0,0 +1,51 @@ +using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Core.Test.Utilities; + +public class EmailValidationTests +{ + [Theory] + [InlineData("user@Example.COM", "example.com")] + [InlineData("user@EXAMPLE.COM", "example.com")] + [InlineData("user@example.com", "example.com")] + [InlineData("user@Example.Com", "example.com")] + [InlineData("User@DOMAIN.CO.UK", "domain.co.uk")] + public void GetDomain_WithMixedCaseEmail_ReturnsLowercaseDomain(string email, string expectedDomain) + { + // Act + var result = EmailValidation.GetDomain(email); + + // Assert + Assert.Equal(expectedDomain, result); + } + + [Theory] + [InlineData("hello@world.com", "world.com")] // regular email address + [InlineData("hello@world.planet.com", "world.planet.com")] // subdomain + [InlineData("hello+1@world.com", "world.com")] // alias + [InlineData("hello.there@world.com", "world.com")] // period in local-part + [InlineData("hello@wörldé.com", "wörldé.com")] // unicode domain + [InlineData("hello@world.cömé", "world.cömé")] // unicode top-level domain + public void GetDomain_WithValidEmail_ReturnsLowercaseDomain(string email, string expectedDomain) + { + // Act + var result = EmailValidation.GetDomain(email); + + // Assert + Assert.Equal(expectedDomain, result); + } + + [Theory] + [InlineData("invalid-email")] + [InlineData("@example.com")] + [InlineData("user@")] + [InlineData("")] + public void GetDomain_WithInvalidEmail_ThrowsBadRequestException(string email) + { + // Act & Assert + var exception = Assert.Throws(() => EmailValidation.GetDomain(email)); + Assert.Equal("Invalid email address format.", exception.Message); + } +} diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index 88e8af3dc6..8325dcf1bb 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -242,7 +242,7 @@ public class AccountsControllerTests : IClassFixture var orgInviteToken = "BwOrgUserInviteToken_CfDJ8HOzu6wr6nVLouuDxgOHsMwPcj9Guuip5k_XLD1bBGpwQS1f66c9kB6X4rvKGxNdywhgimzgvG9SgLwwJU70O8P879XyP94W6kSoT4N25a73kgW3nU3vl3fAtGSS52xdBjNU8o4sxmomRvhOZIQ0jwtVjdMC2IdybTbxwCZhvN0hKIFs265k6wFRSym1eu4NjjZ8pmnMneG0PlKnNZL93tDe8FMcqStJXoddIEgbA99VJp8z1LQmOMfEdoMEM7Zs8W5bZ34N4YEGu8XCrVau59kGtWQk7N4rPV5okzQbTpeoY_4FeywgLFGm-tDtTPEdSEBJkRjexANri7CGdg3dpnMifQc_bTmjZd32gOjw8N8v"; var orgUserId = new Guid("5e45fbdc-a080-4a77-93ff-b19c0161e81e"); - var orgUser = new OrganizationUser { Id = orgUserId, Email = email }; + var orgUser = new OrganizationUser { Id = orgUserId, Email = email, OrganizationId = Guid.NewGuid() }; var orgInviteTokenable = new OrgUserInviteTokenable(orgUser) { @@ -259,6 +259,12 @@ public class AccountsControllerTests : IClassFixture }); }); + localFactory.SubstituteService(orgUserRepository => + { + orgUserRepository.GetByIdAsync(orgUserId) + .Returns(orgUser); + }); + var registerFinishReqModel = new RegisterFinishRequestModel { Email = email, diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationDomainRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationDomainRepositoryTests.cs index 6c1ac00073..74a4fb13ee 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationDomainRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationDomainRepositoryTests.cs @@ -1,4 +1,6 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Entities; using Bit.Core.Repositories; using Xunit; @@ -7,7 +9,7 @@ namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories; public class OrganizationDomainRepositoryTests { - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetExpiredOrganizationDomainsAsync_ShouldReturn3DaysOldUnverifiedDomains( IUserRepository userRepository, IOrganizationRepository organizationRepository, @@ -74,7 +76,7 @@ public class OrganizationDomainRepositoryTests Assert.NotNull(expectedDomain2); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetExpiredOrganizationDomainsAsync_ShouldNotReturnDomainsUnder3DaysOld( IUserRepository userRepository, IOrganizationRepository organizationRepository, @@ -120,7 +122,7 @@ public class OrganizationDomainRepositoryTests Assert.Null(expectedDomain2); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetExpiredOrganizationDomainsAsync_ShouldNotReturnVerifiedDomains( IUserRepository userRepository, IOrganizationRepository organizationRepository, @@ -189,7 +191,7 @@ public class OrganizationDomainRepositoryTests Assert.Null(expectedDomain2); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetManyByNextRunDateAsync_ShouldReturnUnverifiedDomains( IOrganizationRepository organizationRepository, IOrganizationDomainRepository organizationDomainRepository) @@ -228,7 +230,7 @@ public class OrganizationDomainRepositoryTests Assert.NotNull(expectedDomain); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetManyByNextRunDateAsync_ShouldNotReturnUnverifiedDomains_WhenNextRunDateIsOutside36hoursWindow( IOrganizationRepository organizationRepository, IOrganizationDomainRepository organizationDomainRepository) @@ -267,7 +269,7 @@ public class OrganizationDomainRepositoryTests Assert.Null(expectedDomain); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetManyByNextRunDateAsync_ShouldNotReturnVerifiedDomains( IOrganizationRepository organizationRepository, IOrganizationDomainRepository organizationDomainRepository) @@ -307,7 +309,7 @@ public class OrganizationDomainRepositoryTests Assert.Null(expectedDomain); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetVerifiedDomainsByOrganizationIdsAsync_ShouldVerifiedDomainsMatchesOrganizationIds( IOrganizationRepository organizationRepository, IOrganizationDomainRepository organizationDomainRepository) @@ -383,4 +385,437 @@ public class OrganizationDomainRepositoryTests Assert.Null(otherOrganizationDomain); Assert.Null(unverifiedDomain); } + + [Theory, DatabaseData] + public async Task HasVerifiedDomainWithBlockClaimedDomainPolicyAsync_WithVerifiedDomainAndBlockPolicy_ReturnsTrue( + IOrganizationRepository organizationRepository, + IOrganizationDomainRepository organizationDomainRepository, + IPolicyRepository policyRepository) + { + // Arrange + var id = Guid.NewGuid(); + var domainName = $"test-{id}.example.com"; + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = $"test+{id}@example.com", + Plan = "Test", + PrivateKey = "privatekey", + Enabled = true, + UsePolicies = true, + UseOrganizationDomains = true + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345" + }; + organizationDomain.SetNextRunDate(1); + organizationDomain.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.BlockClaimedDomainAccountCreation, + Enabled = true + }; + await policyRepository.CreateAsync(policy); + + // Act + var result = await organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(domainName); + + // Assert + Assert.True(result); + } + + [Theory, DatabaseData] + public async Task HasVerifiedDomainWithBlockClaimedDomainPolicyAsync_WithUnverifiedDomain_ReturnsFalse( + IOrganizationRepository organizationRepository, + IOrganizationDomainRepository organizationDomainRepository, + IPolicyRepository policyRepository) + { + // Arrange + var id = Guid.NewGuid(); + var domainName = $"test-{id}.example.com"; + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = $"test+{id}@example.com", + Plan = "Test", + PrivateKey = "privatekey", + Enabled = true, + UsePolicies = true, + UseOrganizationDomains = true + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345" + }; + organizationDomain.SetNextRunDate(1); + // Do not verify the domain + await organizationDomainRepository.CreateAsync(organizationDomain); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.BlockClaimedDomainAccountCreation, + Enabled = true + }; + await policyRepository.CreateAsync(policy); + + // Act + var result = await organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(domainName); + + // Assert + Assert.False(result); + } + + [Theory, DatabaseData] + public async Task HasVerifiedDomainWithBlockClaimedDomainPolicyAsync_WithDisabledPolicy_ReturnsFalse( + IOrganizationRepository organizationRepository, + IOrganizationDomainRepository organizationDomainRepository, + IPolicyRepository policyRepository) + { + // Arrange + var id = Guid.NewGuid(); + var domainName = $"test-{id}.example.com"; + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = $"test+{id}@example.com", + Plan = "Test", + PrivateKey = "privatekey", + Enabled = true, + UsePolicies = true, + UseOrganizationDomains = true + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345" + }; + organizationDomain.SetNextRunDate(1); + organizationDomain.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.BlockClaimedDomainAccountCreation, + Enabled = false + }; + await policyRepository.CreateAsync(policy); + + // Act + var result = await organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(domainName); + + // Assert + Assert.False(result); + } + + [Theory, DatabaseData] + public async Task HasVerifiedDomainWithBlockClaimedDomainPolicyAsync_WithDisabledOrganization_ReturnsFalse( + IOrganizationRepository organizationRepository, + IOrganizationDomainRepository organizationDomainRepository, + IPolicyRepository policyRepository) + { + // Arrange + var id = Guid.NewGuid(); + var domainName = $"test-{id}.example.com"; + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = $"test+{id}@example.com", + Plan = "Test", + PrivateKey = "privatekey", + Enabled = false, + UsePolicies = true, + UseOrganizationDomains = true + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345" + }; + organizationDomain.SetNextRunDate(1); + organizationDomain.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.BlockClaimedDomainAccountCreation, + Enabled = true + }; + await policyRepository.CreateAsync(policy); + + // Act + var result = await organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(domainName); + + // Assert + Assert.False(result); + } + + [Theory, DatabaseData] + public async Task HasVerifiedDomainWithBlockClaimedDomainPolicyAsync_WithUsePoliciesFalse_ReturnsFalse( + IOrganizationRepository organizationRepository, + IOrganizationDomainRepository organizationDomainRepository, + IPolicyRepository policyRepository) + { + // Arrange + var id = Guid.NewGuid(); + var domainName = $"test-{id}.example.com"; + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = $"test+{id}@example.com", + Plan = "Test", + PrivateKey = "privatekey", + Enabled = true, + UsePolicies = false, // Organization doesn't have policies feature + UseOrganizationDomains = true + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345" + }; + organizationDomain.SetNextRunDate(1); + organizationDomain.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.BlockClaimedDomainAccountCreation, + Enabled = true + }; + await policyRepository.CreateAsync(policy); + + // Act + var result = await organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(domainName); + + // Assert + Assert.False(result); + } + + [Theory, DatabaseData] + public async Task HasVerifiedDomainWithBlockClaimedDomainPolicyAsync_WithUseOrganizationDomainsFalse_ReturnsFalse( + IOrganizationRepository organizationRepository, + IOrganizationDomainRepository organizationDomainRepository, + IPolicyRepository policyRepository) + { + // Arrange + var id = Guid.NewGuid(); + var domainName = $"test-{id}.example.com"; + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = $"test+{id}@example.com", + Plan = "Test", + PrivateKey = "privatekey", + Enabled = true, + UsePolicies = true, + UseOrganizationDomains = false // Organization doesn't have organization domains feature + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345" + }; + organizationDomain.SetNextRunDate(1); + organizationDomain.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.BlockClaimedDomainAccountCreation, + Enabled = true + }; + await policyRepository.CreateAsync(policy); + + // Act + var result = await organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(domainName); + + // Assert + Assert.False(result); + } + + [Theory, DatabaseData] + public async Task HasVerifiedDomainWithBlockClaimedDomainPolicyAsync_WithNoPolicyOfType_ReturnsFalse( + IOrganizationRepository organizationRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + // Arrange + var id = Guid.NewGuid(); + var domainName = $"test-{id}.example.com"; + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = $"test+{id}@example.com", + Plan = "Test", + PrivateKey = "privatekey", + Enabled = true, + UsePolicies = true, + UseOrganizationDomains = true + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345" + }; + organizationDomain.SetNextRunDate(1); + organizationDomain.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + // No policy created + + // Act + var result = await organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(domainName); + + // Assert + Assert.False(result); + } + + [Theory, DatabaseData] + public async Task HasVerifiedDomainWithBlockClaimedDomainPolicyAsync_WithNonExistentDomain_ReturnsFalse( + IOrganizationDomainRepository organizationDomainRepository) + { + // Arrange + var domainName = $"nonexistent-{Guid.NewGuid()}.example.com"; + + // Act + var result = await organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(domainName); + + // Assert + Assert.False(result); + } + + [Theory, DatabaseData] + public async Task HasVerifiedDomainWithBlockClaimedDomainPolicyAsync_ExcludeOrganization_WhenSameOrg_ReturnsFalse( + IOrganizationRepository organizationRepository, + IOrganizationDomainRepository organizationDomainRepository, + IPolicyRepository policyRepository) + { + // Arrange + var id = Guid.NewGuid(); + var domainName = $"test-{id}.example.com"; + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = $"test+{id}@example.com", + Plan = "Test", + PrivateKey = "privatekey", + Enabled = true, + UsePolicies = true, + UseOrganizationDomains = true + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345" + }; + organizationDomain.SetNextRunDate(1); + organizationDomain.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.BlockClaimedDomainAccountCreation, + Enabled = true + }; + await policyRepository.CreateAsync(policy); + + // Act - Exclude the same organization that has the domain + var result = await organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(domainName, organization.Id); + + // Assert - Should return false because we're excluding the only org with this domain + Assert.False(result); + } + + [Theory, DatabaseData] + public async Task HasVerifiedDomainWithBlockClaimedDomainPolicyAsync_ExcludeOrganization_WhenDifferentOrg_ReturnsTrue( + IOrganizationRepository organizationRepository, + IOrganizationDomainRepository organizationDomainRepository, + IPolicyRepository policyRepository) + { + // Arrange + var id = Guid.NewGuid(); + var domainName = $"test-{id}.example.com"; + + var organization1 = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org 1 {id}", + BillingEmail = $"test1+{id}@example.com", + Plan = "Test", + PrivateKey = "privatekey", + Enabled = true, + UsePolicies = true, + UseOrganizationDomains = true + }); + + var organizationDomain1 = new OrganizationDomain + { + OrganizationId = organization1.Id, + DomainName = domainName, + Txt = "btw+12345" + }; + organizationDomain1.SetNextRunDate(1); + organizationDomain1.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(organizationDomain1); + + var policy1 = new Policy + { + OrganizationId = organization1.Id, + Type = PolicyType.BlockClaimedDomainAccountCreation, + Enabled = true + }; + await policyRepository.CreateAsync(policy1); + + // Create a second organization (the one we'll exclude) + var organization2 = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org 2 {id}", + BillingEmail = $"test2+{id}@example.com", + Plan = "Test", + PrivateKey = "privatekey", + Enabled = true, + UsePolicies = true, + UseOrganizationDomains = true + }); + + // Act - Exclude organization2 (but organization1 still has the domain blocked) + var result = await organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(domainName, organization2.Id); + + // Assert - Should return true because organization1 (not excluded) has the domain blocked + Assert.True(result); + } } diff --git a/util/Migrator/DbScripts/2025-11-04_00_BlockClaimedDomainAccountCreationPolicy.sql b/util/Migrator/DbScripts/2025-11-04_00_BlockClaimedDomainAccountCreationPolicy.sql new file mode 100644 index 0000000000..04f09b080b --- /dev/null +++ b/util/Migrator/DbScripts/2025-11-04_00_BlockClaimedDomainAccountCreationPolicy.sql @@ -0,0 +1,41 @@ +-- Add stored procedure for checking if a domain has the BlockClaimedDomainAccountCreation policy enabled +-- This supports the BlockClaimedDomainAccountCreation policy (Type = 19) which prevents users from +-- creating personal accounts using email addresses from domains claimed by organizations. +-- The optional @ExcludeOrganizationId parameter allows excluding a specific organization from the check, +-- enabling users to join the organization that owns their email domain. + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationDomain_HasVerifiedDomainWithBlockPolicy] + @DomainName NVARCHAR(255), + @ExcludeOrganizationId UNIQUEIDENTIFIER = NULL +AS +BEGIN + SET NOCOUNT ON + + -- Check if any organization has a verified domain matching the domain name + -- with the BlockClaimedDomainAccountCreation policy enabled (Type = 19) + -- If @ExcludeOrganizationId is provided, exclude that organization from the check + IF EXISTS ( + SELECT 1 + FROM [dbo].[OrganizationDomain] OD + INNER JOIN [dbo].[Organization] O + ON OD.OrganizationId = O.Id + INNER JOIN [dbo].[Policy] P + ON O.Id = P.OrganizationId + WHERE OD.DomainName = @DomainName + AND OD.VerifiedDate IS NOT NULL + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND O.UseOrganizationDomains = 1 + AND (@ExcludeOrganizationId IS NULL OR O.Id != @ExcludeOrganizationId) + AND P.Type = 19 -- BlockClaimedDomainAccountCreation + AND P.Enabled = 1 + ) + BEGIN + SELECT CAST(1 AS BIT) AS HasBlockPolicy + END + ELSE + BEGIN + SELECT CAST(0 AS BIT) AS HasBlockPolicy + END +END +GO