mirror of
https://github.com/bitwarden/server
synced 2025-12-15 15:53:59 +00:00
[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>
This commit is contained in:
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ public static class PolicyServiceCollectionExtensions
|
||||
services.AddScoped<IPolicyUpdateEvent, FreeFamiliesForEnterprisePolicyValidator>();
|
||||
services.AddScoped<IPolicyUpdateEvent, OrganizationDataOwnershipPolicyValidator>();
|
||||
services.AddScoped<IPolicyUpdateEvent, UriMatchDefaultPolicyValidator>();
|
||||
services.AddScoped<IPolicyUpdateEvent, BlockClaimedDomainAccountCreationPolicyValidator>();
|
||||
services.AddScoped<IPolicyUpdateEvent, AutomaticUserConfirmationPolicyEventHandler>();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PolicyType> RequiredPolicies => [];
|
||||
|
||||
public async Task<string> ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy)
|
||||
{
|
||||
return await ValidateAsync(policyRequest.PolicyUpdate, currentPolicy);
|
||||
}
|
||||
|
||||
public async Task<string> 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;
|
||||
}
|
||||
@@ -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<RegisterUserCommand> _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<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;
|
||||
@@ -37,28 +41,32 @@ public class RegisterUserCommand : IRegisterUserCommand
|
||||
private readonly IValidateRedemptionTokenCommand _validateRedemptionTokenCommand;
|
||||
|
||||
private readonly IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> _emergencyAccessInviteTokenDataFactory;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
private readonly string _disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator.";
|
||||
|
||||
public RegisterUserCommand(
|
||||
ILogger<RegisterUserCommand> logger,
|
||||
IGlobalSettings globalSettings,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository,
|
||||
IFeatureService featureService,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> registrationEmailVerificationTokenDataFactory,
|
||||
IUserService userService,
|
||||
IMailService mailService,
|
||||
IValidateRedemptionTokenCommand validateRedemptionTokenCommand,
|
||||
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> emergencyAccessInviteTokenDataFactory,
|
||||
IFeatureService featureService)
|
||||
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> 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<IdentityResult> 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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
|
||||
@@ -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;
|
||||
/// </summary>
|
||||
public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmailForRegistrationCommand
|
||||
{
|
||||
|
||||
private readonly ILogger<SendVerificationEmailForRegistrationCommand> _logger;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _tokenDataFactory;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IOrganizationDomainRepository _organizationDomainRepository;
|
||||
|
||||
public SendVerificationEmailForRegistrationCommand(
|
||||
ILogger<SendVerificationEmailForRegistrationCommand> logger,
|
||||
IUserRepository userRepository,
|
||||
GlobalSettings globalSettings,
|
||||
IMailService mailService,
|
||||
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> 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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -17,4 +17,5 @@ public interface IOrganizationDomainRepository : IRepository<OrganizationDomain,
|
||||
Task<OrganizationDomain?> GetDomainByOrgIdAndDomainNameAsync(Guid orgId, string domainName);
|
||||
Task<ICollection<OrganizationDomain>> GetExpiredOrganizationDomainsAsync();
|
||||
Task<bool> DeleteExpiredAsync(int expirationPeriod);
|
||||
Task<bool> HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(string domainName, Guid? excludeOrganizationId = null);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the domain portion from an email address and normalizes it to lowercase.
|
||||
/// </summary>
|
||||
/// <param name="email">The email address to extract the domain from.</param>
|
||||
/// <returns>The domain portion of the email address in lowercase (e.g., "example.com").</returns>
|
||||
/// <exception cref="BadRequestException">Thrown when the email address format is invalid.</exception>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,4 +148,16 @@ public class OrganizationDomainRepository : Repository<OrganizationDomain, Guid>
|
||||
commandType: CommandType.StoredProcedure) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(string domainName, Guid? excludeOrganizationId = null)
|
||||
{
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
|
||||
var result = await connection.QueryFirstOrDefaultAsync<bool>(
|
||||
$"[{Schema}].[OrganizationDomain_HasVerifiedDomainWithBlockPolicy]",
|
||||
new { DomainName = domainName, ExcludeOrganizationId = excludeOrganizationId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,5 +177,25 @@ public class OrganizationDomainRepository : Repository<Core.Entities.Organizatio
|
||||
return Mapper.Map<List<OrganizationDomain>>(verifiedDomains);
|
||||
}
|
||||
|
||||
public async Task<bool> 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user