Skip to content

Commit c0700a6

Browse files
kspearrineliykat
andauthored
[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 <[email protected]> * fix merge issues * fix tests --------- Co-authored-by: Thomas Rittson <[email protected]>
1 parent 55fb80b commit c0700a6

File tree

18 files changed

+1502
-18
lines changed

18 files changed

+1502
-18
lines changed

src/Core/AdminConsole/Enums/PolicyType.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public enum PolicyType : byte
2121
UriMatchDefaults = 16,
2222
AutotypeDefaultSetting = 17,
2323
AutomaticUserConfirmation = 18,
24+
BlockClaimedDomainAccountCreation = 19,
2425
}
2526

2627
public static class PolicyTypeExtensions
@@ -52,6 +53,7 @@ public static string GetName(this PolicyType type)
5253
PolicyType.UriMatchDefaults => "URI match defaults",
5354
PolicyType.AutotypeDefaultSetting => "Autotype default setting",
5455
PolicyType.AutomaticUserConfirmation => "Automatically confirm invited users",
56+
PolicyType.BlockClaimedDomainAccountCreation => "Block account creation for claimed domains",
5557
};
5658
}
5759
}

src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ private static void AddPolicyUpdateEvents(this IServiceCollection services)
5353
services.AddScoped<IPolicyUpdateEvent, FreeFamiliesForEnterprisePolicyValidator>();
5454
services.AddScoped<IPolicyUpdateEvent, OrganizationDataOwnershipPolicyValidator>();
5555
services.AddScoped<IPolicyUpdateEvent, UriMatchDefaultPolicyValidator>();
56+
services.AddScoped<IPolicyUpdateEvent, BlockClaimedDomainAccountCreationPolicyValidator>();
5657
services.AddScoped<IPolicyUpdateEvent, AutomaticUserConfirmationPolicyEventHandler>();
5758
}
5859

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#nullable enable
2+
3+
using Bit.Core.AdminConsole.Entities;
4+
using Bit.Core.AdminConsole.Enums;
5+
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
6+
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
7+
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
8+
using Bit.Core.Services;
9+
10+
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
11+
12+
public class BlockClaimedDomainAccountCreationPolicyValidator : IPolicyValidator, IPolicyValidationEvent
13+
{
14+
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
15+
private readonly IFeatureService _featureService;
16+
17+
public BlockClaimedDomainAccountCreationPolicyValidator(
18+
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
19+
IFeatureService featureService)
20+
{
21+
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
22+
_featureService = featureService;
23+
}
24+
25+
public PolicyType Type => PolicyType.BlockClaimedDomainAccountCreation;
26+
27+
// No prerequisites - this policy stands alone
28+
public IEnumerable<PolicyType> RequiredPolicies => [];
29+
30+
public async Task<string> ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy)
31+
{
32+
return await ValidateAsync(policyRequest.PolicyUpdate, currentPolicy);
33+
}
34+
35+
public async Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
36+
{
37+
// Check if feature is enabled
38+
if (!_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation))
39+
{
40+
return "This feature is not enabled";
41+
}
42+
43+
// Only validate when trying to ENABLE the policy
44+
if (policyUpdate is { Enabled: true })
45+
{
46+
// Check if organization has at least one verified domain
47+
if (!await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId))
48+
{
49+
return "You must claim at least one domain to turn on this policy";
50+
}
51+
}
52+
53+
// Disabling the policy is always allowed
54+
return string.Empty;
55+
}
56+
57+
public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
58+
=> Task.CompletedTask;
59+
}

src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,20 @@
1515
using Bit.Core.Utilities;
1616
using Microsoft.AspNetCore.DataProtection;
1717
using Microsoft.AspNetCore.Identity;
18+
using Microsoft.Extensions.Logging;
1819
using Newtonsoft.Json;
1920

2021
namespace Bit.Core.Auth.UserFeatures.Registration.Implementations;
2122

2223
public class RegisterUserCommand : IRegisterUserCommand
2324
{
25+
private readonly ILogger<RegisterUserCommand> _logger;
2426
private readonly IGlobalSettings _globalSettings;
2527
private readonly IOrganizationUserRepository _organizationUserRepository;
2628
private readonly IOrganizationRepository _organizationRepository;
2729
private readonly IPolicyRepository _policyRepository;
30+
private readonly IOrganizationDomainRepository _organizationDomainRepository;
31+
private readonly IFeatureService _featureService;
2832

2933
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
3034
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;
@@ -37,28 +41,32 @@ public class RegisterUserCommand : IRegisterUserCommand
3741
private readonly IValidateRedemptionTokenCommand _validateRedemptionTokenCommand;
3842

3943
private readonly IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> _emergencyAccessInviteTokenDataFactory;
40-
private readonly IFeatureService _featureService;
4144

4245
private readonly string _disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator.";
4346

4447
public RegisterUserCommand(
48+
ILogger<RegisterUserCommand> logger,
4549
IGlobalSettings globalSettings,
4650
IOrganizationUserRepository organizationUserRepository,
4751
IOrganizationRepository organizationRepository,
4852
IPolicyRepository policyRepository,
53+
IOrganizationDomainRepository organizationDomainRepository,
54+
IFeatureService featureService,
4955
IDataProtectionProvider dataProtectionProvider,
5056
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
5157
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> registrationEmailVerificationTokenDataFactory,
5258
IUserService userService,
5359
IMailService mailService,
5460
IValidateRedemptionTokenCommand validateRedemptionTokenCommand,
55-
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> emergencyAccessInviteTokenDataFactory,
56-
IFeatureService featureService)
61+
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> emergencyAccessInviteTokenDataFactory)
5762
{
63+
_logger = logger;
5864
_globalSettings = globalSettings;
5965
_organizationUserRepository = organizationUserRepository;
6066
_organizationRepository = organizationRepository;
6167
_policyRepository = policyRepository;
68+
_organizationDomainRepository = organizationDomainRepository;
69+
_featureService = featureService;
6270

6371
_organizationServiceDataProtector = dataProtectionProvider.CreateProtector(
6472
"OrganizationServiceDataProtector");
@@ -77,6 +85,8 @@ public RegisterUserCommand(
7785

7886
public async Task<IdentityResult> RegisterUser(User user)
7987
{
88+
await ValidateEmailDomainNotBlockedAsync(user.Email);
89+
8090
var result = await _userService.CreateUserAsync(user);
8191
if (result == IdentityResult.Success)
8292
{
@@ -102,6 +112,11 @@ public async Task<IdentityResult> RegisterUserViaOrganizationInviteToken(User us
102112
{
103113
TryValidateOrgInviteToken(orgInviteToken, orgUserId, user);
104114
var orgUser = await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user);
115+
if (orgUser == null && orgUserId.HasValue)
116+
{
117+
throw new BadRequestException("Invalid organization user invitation.");
118+
}
119+
await ValidateEmailDomainNotBlockedAsync(user.Email, orgUser?.OrganizationId);
105120

106121
user.ApiKey = CoreHelpers.SecureRandomString(30);
107122

@@ -265,6 +280,8 @@ public async Task<IdentityResult> RegisterUserViaEmailVerificationToken(User use
265280
string emailVerificationToken)
266281
{
267282
ValidateOpenRegistrationAllowed();
283+
await ValidateEmailDomainNotBlockedAsync(user.Email);
284+
268285
var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email);
269286

270287
user.EmailVerified = true;
@@ -284,6 +301,7 @@ public async Task<IdentityResult> RegisterUserViaOrganizationSponsoredFreeFamily
284301
string orgSponsoredFreeFamilyPlanInviteToken)
285302
{
286303
ValidateOpenRegistrationAllowed();
304+
await ValidateEmailDomainNotBlockedAsync(user.Email);
287305
await ValidateOrgSponsoredFreeFamilyPlanInviteToken(orgSponsoredFreeFamilyPlanInviteToken, user.Email);
288306

289307
user.EmailVerified = true;
@@ -304,6 +322,7 @@ public async Task<IdentityResult> RegisterUserViaAcceptEmergencyAccessInviteToke
304322
string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId)
305323
{
306324
ValidateOpenRegistrationAllowed();
325+
await ValidateEmailDomainNotBlockedAsync(user.Email);
307326
ValidateAcceptEmergencyAccessInviteToken(acceptEmergencyAccessInviteToken, acceptEmergencyAccessId, user.Email);
308327

309328
user.EmailVerified = true;
@@ -322,6 +341,7 @@ public async Task<IdentityResult> RegisterUserViaProviderInviteToken(User user,
322341
string providerInviteToken, Guid providerUserId)
323342
{
324343
ValidateOpenRegistrationAllowed();
344+
await ValidateEmailDomainNotBlockedAsync(user.Email);
325345
ValidateProviderInviteToken(providerInviteToken, providerUserId, user.Email);
326346

327347
user.EmailVerified = true;
@@ -387,6 +407,28 @@ private RegistrationEmailVerificationTokenable ValidateRegistrationEmailVerifica
387407
return tokenable;
388408
}
389409

410+
private async Task ValidateEmailDomainNotBlockedAsync(string email, Guid? excludeOrganizationId = null)
411+
{
412+
// Only check if feature flag is enabled
413+
if (!_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation))
414+
{
415+
return;
416+
}
417+
418+
var emailDomain = EmailValidation.GetDomain(email);
419+
420+
var isDomainBlocked = await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(
421+
emailDomain, excludeOrganizationId);
422+
if (isDomainBlocked)
423+
{
424+
_logger.LogInformation(
425+
"User registration blocked by domain claim policy. Domain: {Domain}, ExcludedOrgId: {ExcludedOrgId}",
426+
emailDomain,
427+
excludeOrganizationId);
428+
throw new BadRequestException("This email address is claimed by an organization using Bitwarden.");
429+
}
430+
}
431+
390432
/// <summary>
391433
/// We send different welcome emails depending on whether the user is joining a free/family or an enterprise organization. If information to populate the
392434
/// email isn't present we send the standard individual welcome email.

src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using Bit.Core.Services;
66
using Bit.Core.Settings;
77
using Bit.Core.Tokens;
8+
using Bit.Core.Utilities;
9+
using Microsoft.Extensions.Logging;
810

911
namespace Bit.Core.Auth.UserFeatures.Registration.Implementations;
1012

@@ -15,25 +17,30 @@ namespace Bit.Core.Auth.UserFeatures.Registration.Implementations;
1517
/// </summary>
1618
public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmailForRegistrationCommand
1719
{
18-
20+
private readonly ILogger<SendVerificationEmailForRegistrationCommand> _logger;
1921
private readonly IUserRepository _userRepository;
2022
private readonly GlobalSettings _globalSettings;
2123
private readonly IMailService _mailService;
2224
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _tokenDataFactory;
2325
private readonly IFeatureService _featureService;
26+
private readonly IOrganizationDomainRepository _organizationDomainRepository;
2427

2528
public SendVerificationEmailForRegistrationCommand(
29+
ILogger<SendVerificationEmailForRegistrationCommand> logger,
2630
IUserRepository userRepository,
2731
GlobalSettings globalSettings,
2832
IMailService mailService,
2933
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> tokenDataFactory,
30-
IFeatureService featureService)
34+
IFeatureService featureService,
35+
IOrganizationDomainRepository organizationDomainRepository)
3136
{
37+
_logger = logger;
3238
_userRepository = userRepository;
3339
_globalSettings = globalSettings;
3440
_mailService = mailService;
3541
_tokenDataFactory = tokenDataFactory;
3642
_featureService = featureService;
43+
_organizationDomainRepository = organizationDomainRepository;
3744

3845
}
3946

@@ -49,6 +56,20 @@ public SendVerificationEmailForRegistrationCommand(
4956
throw new ArgumentNullException(nameof(email));
5057
}
5158

59+
// Check if the email domain is blocked by an organization policy
60+
if (_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation))
61+
{
62+
var emailDomain = EmailValidation.GetDomain(email);
63+
64+
if (await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(emailDomain))
65+
{
66+
_logger.LogInformation(
67+
"User registration email verification blocked by domain claim policy. Domain: {Domain}",
68+
emailDomain);
69+
throw new BadRequestException("This email address is claimed by an organization using Bitwarden.");
70+
}
71+
}
72+
5273
// Check to see if the user already exists
5374
var user = await _userRepository.GetByEmailAsync(email);
5475
var userExists = user != null;

src/Core/Constants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ public static class FeatureFlagKeys
141141
public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users";
142142
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
143143
public const string AccountRecoveryCommand = "pm-25581-prevent-provider-account-recovery";
144+
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
144145
public const string PolicyValidatorsRefactor = "pm-26423-refactor-policy-side-effects";
145146

146147
/* Architecture */

src/Core/Repositories/IOrganizationDomainRepository.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ public interface IOrganizationDomainRepository : IRepository<OrganizationDomain,
1717
Task<OrganizationDomain?> GetDomainByOrgIdAndDomainNameAsync(Guid orgId, string domainName);
1818
Task<ICollection<OrganizationDomain>> GetExpiredOrganizationDomainsAsync();
1919
Task<bool> DeleteExpiredAsync(int expirationPeriod);
20+
Task<bool> HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(string domainName, Guid? excludeOrganizationId = null);
2021
}

src/Core/Utilities/EmailValidation.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
using System.Text.RegularExpressions;
1+
using System.Net.Mail;
2+
using System.Text.RegularExpressions;
3+
using Bit.Core.Exceptions;
24
using MimeKit;
35

46
namespace Bit.Core.Utilities;
@@ -41,4 +43,22 @@ public static bool IsValidEmail(this string emailAddress)
4143

4244
return true;
4345
}
46+
47+
/// <summary>
48+
/// Extracts the domain portion from an email address and normalizes it to lowercase.
49+
/// </summary>
50+
/// <param name="email">The email address to extract the domain from.</param>
51+
/// <returns>The domain portion of the email address in lowercase (e.g., "example.com").</returns>
52+
/// <exception cref="BadRequestException">Thrown when the email address format is invalid.</exception>
53+
public static string GetDomain(string email)
54+
{
55+
try
56+
{
57+
return new MailAddress(email).Host.ToLower();
58+
}
59+
catch (Exception ex) when (ex is FormatException || ex is ArgumentException)
60+
{
61+
throw new BadRequestException("Invalid email address format.");
62+
}
63+
}
4464
}

src/Infrastructure.Dapper/Repositories/OrganizationDomainRepository.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,16 @@ public async Task<bool> DeleteExpiredAsync(int expirationPeriod)
148148
commandType: CommandType.StoredProcedure) > 0;
149149
}
150150
}
151+
152+
public async Task<bool> HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(string domainName, Guid? excludeOrganizationId = null)
153+
{
154+
await using var connection = new SqlConnection(ConnectionString);
155+
156+
var result = await connection.QueryFirstOrDefaultAsync<bool>(
157+
$"[{Schema}].[OrganizationDomain_HasVerifiedDomainWithBlockPolicy]",
158+
new { DomainName = domainName, ExcludeOrganizationId = excludeOrganizationId },
159+
commandType: CommandType.StoredProcedure);
160+
161+
return result;
162+
}
151163
}

src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,5 +177,25 @@ where organizationIds.Contains(d.OrganizationId) && d.VerifiedDate != null
177177
return Mapper.Map<List<OrganizationDomain>>(verifiedDomains);
178178
}
179179

180+
public async Task<bool> HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(string domainName, Guid? excludeOrganizationId = null)
181+
{
182+
using var scope = ServiceScopeFactory.CreateScope();
183+
var dbContext = GetDatabaseContext(scope);
184+
185+
var query = from od in dbContext.OrganizationDomains
186+
join o in dbContext.Organizations on od.OrganizationId equals o.Id
187+
join p in dbContext.Policies on o.Id equals p.OrganizationId
188+
where od.DomainName == domainName
189+
&& od.VerifiedDate != null
190+
&& o.Enabled
191+
&& o.UsePolicies
192+
&& o.UseOrganizationDomains
193+
&& (!excludeOrganizationId.HasValue || o.Id != excludeOrganizationId.Value)
194+
&& p.Type == Core.AdminConsole.Enums.PolicyType.BlockClaimedDomainAccountCreation
195+
&& p.Enabled
196+
select od;
197+
198+
return await query.AnyAsync();
199+
}
180200
}
181201

0 commit comments

Comments
 (0)