1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-03 00:52:49 -05:00

[AC-1698] Check if a user has 2FA enabled more efficiently (#4524)

* feat: Add stored procedure for reading organization user details with premium access by organization ID

The code changes include:
- Addition of a new stored procedure [dbo].[OrganizationUserUserDetailsWithPremiumAccess_ReadByOrganizationId] to read organization user details with premium access by organization ID
- Modification of the IUserService interface to include an optional parameter for checking two-factor authentication with premium access
- Modification of the UserService class to handle the new optional parameter in the TwoFactorIsEnabledAsync method
- Addition of a new method GetManyDetailsWithPremiumAccessByOrganizationAsync in the IOrganizationUserRepository interface to retrieve organization user details with premium access by organization ID
- Addition of a new view [dbo].[OrganizationUserUserDetailsWithPremiumAccessView] to retrieve organization user details with premium access

* Add IUserRepository.SearchDetailsAsync that includes the field HasPremiumAccess

* Check the feature flag on Admin.UsersController to see if the optimization runs

* Modify PolicyService to run query optimization if the feature flag is enabled

* Refactor the parameter check on UserService.TwoFactorIsEnabledAsync

* Run query optimization on public MembersController if feature flag is enabled

* Restore refactor

* Reverted change used for development

* Add unit tests for OrganizationService.RestoreUser

* Separate new CheckPoliciesBeforeRestoreAsync optimization into new method

* Add more unit tests

* Apply refactor to bulk restore

* Add GetManyDetailsAsync method to IUserRepository. Add ConfirmUsersAsync_vNext method to IOrganizationService

* Add unit tests for ConfirmUser_vNext

* Refactor the optimization to use the new TwoFactorIsEnabledAsync method instead of changing the existing one

* Removed unused sql scripts and added migration script

* Remove unnecessary view

* chore: Remove unused SearchDetailsAsync method from IUserRepository and UserRepository

* refactor: Use UserDetails constructor in UserRepository

* Add summary to IUserRepository.GetManyDetailsAsync

* Add summary descriptions to IUserService.TwoFactorIsEnabledAsync

* Remove obsolete annotation from IUserRepository.UpdateUserKeyAndEncryptedDataAsync

* refactor: Rename UserDetails to UserWithCalculatedPremium across the codebase

* Extract IUserService.TwoFactorIsEnabledAsync into a new TwoFactorIsEnabledQuery class

* Add unit tests for TwoFactorIsEnabledQuery

* Update TwoFactorIsEnabledQueryTests to include additional provider types

* Refactor TwoFactorIsEnabledQuery

* Refactor TwoFactorIsEnabledQuery and update tests

* refactor: Update TwoFactorIsEnabledQueryTests to include test for null TwoFactorProviders

* refactor: Improve TwoFactorIsEnabledQuery and update tests

* refactor: Improve TwoFactorIsEnabledQuery and update tests

* Remove empty <returns> from summary

* Update User_ReadByIdsWithCalculatedPremium stored procedure to accept JSON array of IDs
This commit is contained in:
Rui Tomé
2024-08-08 15:43:45 +01:00
committed by GitHub
parent 19dc7c339b
commit 8d69bb0aaa
21 changed files with 1481 additions and 25 deletions

View File

@ -53,6 +53,8 @@ public interface IOrganizationService
Guid confirmingUserId, IUserService userService);
Task<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync(Guid organizationId, Dictionary<Guid, string> keys,
Guid confirmingUserId, IUserService userService);
Task<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync_vNext(Guid organizationId, Dictionary<Guid, string> keys,
Guid confirmingUserId);
[Obsolete("IDeleteOrganizationUserCommand should be used instead. To be removed by EC-607.")]
Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId);
[Obsolete("IDeleteOrganizationUserCommand should be used instead. To be removed by EC-607.")]

View File

@ -13,6 +13,7 @@ using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Enums;
using Bit.Core.Context;
using Bit.Core.Entities;
@ -67,6 +68,7 @@ public class OrganizationService : IOrganizationService
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IFeatureService _featureService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
public OrganizationService(
IOrganizationRepository organizationRepository,
@ -99,7 +101,8 @@ public class OrganizationService : IOrganizationService
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IDataProtectorTokenFactory<OrgDeleteTokenable> orgDeleteTokenDataFactory,
IProviderRepository providerRepository,
IFeatureService featureService)
IFeatureService featureService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -132,6 +135,7 @@ public class OrganizationService : IOrganizationService
_orgUserInviteTokenableFactory = orgUserInviteTokenableFactory;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_featureService = featureService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
}
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
@ -1291,7 +1295,10 @@ public class OrganizationService : IOrganizationService
public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
Guid confirmingUserId, IUserService userService)
{
var result = await ConfirmUsersAsync(organizationId, new Dictionary<Guid, string>() { { organizationUserId, key } },
var result = _featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization)
? await ConfirmUsersAsync_vNext(organizationId, new Dictionary<Guid, string>() { { organizationUserId, key } },
confirmingUserId)
: await ConfirmUsersAsync(organizationId, new Dictionary<Guid, string>() { { organizationUserId, key } },
confirmingUserId, userService);
if (!result.Any())
@ -1376,6 +1383,77 @@ public class OrganizationService : IOrganizationService
return result;
}
public async Task<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync_vNext(Guid organizationId, Dictionary<Guid, string> keys,
Guid confirmingUserId)
{
var selectedOrganizationUsers = await _organizationUserRepository.GetManyAsync(keys.Keys);
var validSelectedOrganizationUsers = selectedOrganizationUsers
.Where(u => u.Status == OrganizationUserStatusType.Accepted && u.OrganizationId == organizationId && u.UserId != null)
.ToList();
if (!validSelectedOrganizationUsers.Any())
{
return new List<Tuple<OrganizationUser, string>>();
}
var validSelectedUserIds = validSelectedOrganizationUsers.Select(u => u.UserId.Value).ToList();
var organization = await GetOrgById(organizationId);
var allUsersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validSelectedUserIds);
var users = await _userRepository.GetManyWithCalculatedPremiumAsync(validSelectedUserIds);
var usersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(validSelectedUserIds);
var keyedFilteredUsers = validSelectedOrganizationUsers.ToDictionary(u => u.UserId.Value, u => u);
var keyedOrganizationUsers = allUsersOrgs.GroupBy(u => u.UserId.Value)
.ToDictionary(u => u.Key, u => u.ToList());
var succeededUsers = new List<OrganizationUser>();
var result = new List<Tuple<OrganizationUser, string>>();
foreach (var user in users)
{
if (!keyedFilteredUsers.ContainsKey(user.Id))
{
continue;
}
var orgUser = keyedFilteredUsers[user.Id];
var orgUsers = keyedOrganizationUsers.GetValueOrDefault(user.Id, new List<OrganizationUser>());
try
{
if (organization.PlanType == PlanType.Free && (orgUser.Type == OrganizationUserType.Admin
|| orgUser.Type == OrganizationUserType.Owner))
{
// Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this.
var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(user.Id);
if (adminCount > 0)
{
throw new BadRequestException("User can only be an admin of one free organization.");
}
}
var twoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled;
await CheckPolicies_vNext(organizationId, user, orgUsers, twoFactorEnabled);
orgUser.Status = OrganizationUserStatusType.Confirmed;
orgUser.Key = keys[orgUser.Id];
orgUser.Email = null;
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager);
await DeleteAndPushUserRegistrationAsync(organizationId, user.Id);
succeededUsers.Add(orgUser);
result.Add(Tuple.Create(orgUser, ""));
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(orgUser, e.Message));
}
}
await _organizationUserRepository.ReplaceManyAsync(succeededUsers);
return result;
}
internal async Task<(bool canScale, string failureReason)> CanScaleAsync(
Organization organization,
int seatsToAdd)
@ -1485,6 +1563,33 @@ public class OrganizationService : IOrganizationService
}
}
private async Task CheckPolicies_vNext(Guid organizationId, UserWithCalculatedPremium user,
ICollection<OrganizationUser> userOrgs, bool twoFactorEnabled)
{
// Enforce Two Factor Authentication Policy for this organization
var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication))
.Any(p => p.OrganizationId == organizationId);
if (orgRequiresTwoFactor && !twoFactorEnabled)
{
throw new BadRequestException("User does not have two-step login enabled.");
}
var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId);
var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg);
var otherSingleOrgPolicies =
singleOrgPolicies.Where(p => p.OrganizationId != organizationId);
// Enforce Single Organization Policy for this organization
if (hasOtherOrgs && singleOrgPolicies.Any(p => p.OrganizationId == organizationId))
{
throw new BadRequestException("Cannot confirm this member to the organization until they leave or remove all other organizations.");
}
// Enforce Single Organization Policy of other organizations user is a member of
if (otherSingleOrgPolicies.Any())
{
throw new BadRequestException("Cannot confirm this member to the organization because they are in another organization which forbids it.");
}
}
[Obsolete("IDeleteOrganizationUserCommand should be used instead. To be removed by EC-607.")]
public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId)
{
@ -2319,7 +2424,21 @@ public class OrganizationService : IOrganizationService
await AutoAddSeatsAsync(organization, 1);
}
await CheckPoliciesBeforeRestoreAsync(organizationUser, userService);
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
{
var userTwoFactorIsEnabled = false;
// Only check Two Factor Authentication status if the user is linked to a user account
if (organizationUser.UserId.HasValue)
{
userTwoFactorIsEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(new[] { organizationUser.UserId.Value })).FirstOrDefault().twoFactorIsEnabled;
}
await CheckPoliciesBeforeRestoreAsync_vNext(organizationUser, userTwoFactorIsEnabled);
}
else
{
await CheckPoliciesBeforeRestoreAsync(organizationUser, userService);
}
var status = GetPriorActiveOrganizationUserStatusType(organizationUser);
@ -2351,6 +2470,14 @@ public class OrganizationService : IOrganizationService
deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId);
}
// Query Two Factor Authentication status for all users in the organization
// This is an optimization to avoid querying the Two Factor Authentication status for each user individually
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled = null;
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
{
organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(filteredUsers.Select(ou => ou.UserId.Value));
}
var result = new List<Tuple<OrganizationUser, string>>();
foreach (var organizationUser in filteredUsers)
@ -2372,7 +2499,15 @@ public class OrganizationService : IOrganizationService
throw new BadRequestException("Only owners can restore other owners.");
}
await CheckPoliciesBeforeRestoreAsync(organizationUser, userService);
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
{
var twoFactorIsEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(ou => ou.userId == organizationUser.UserId.Value).twoFactorIsEnabled;
await CheckPoliciesBeforeRestoreAsync_vNext(organizationUser, twoFactorIsEnabled);
}
else
{
await CheckPoliciesBeforeRestoreAsync(organizationUser, userService);
}
var status = GetPriorActiveOrganizationUserStatusType(organizationUser);
@ -2438,6 +2573,52 @@ public class OrganizationService : IOrganizationService
}
}
private async Task CheckPoliciesBeforeRestoreAsync_vNext(OrganizationUser orgUser, bool userHasTwoFactorEnabled)
{
// An invited OrganizationUser isn't linked with a user account yet, so these checks are irrelevant
// The user will be subject to the same checks when they try to accept the invite
if (GetPriorActiveOrganizationUserStatusType(orgUser) == OrganizationUserStatusType.Invited)
{
return;
}
var userId = orgUser.UserId.Value;
// Enforce Single Organization Policy of organization user is being restored to
var allOrgUsers = await _organizationUserRepository.GetManyByUserAsync(userId);
var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId);
var singleOrgPoliciesApplyingToRevokedUsers = await _policyService.GetPoliciesApplicableToUserAsync(userId,
PolicyType.SingleOrg, OrganizationUserStatusType.Revoked);
var singleOrgPolicyApplies = singleOrgPoliciesApplyingToRevokedUsers.Any(p => p.OrganizationId == orgUser.OrganizationId);
if (hasOtherOrgs && singleOrgPolicyApplies)
{
throw new BadRequestException("You cannot restore this user until " +
"they leave or remove all other organizations.");
}
// Enforce Single Organization Policy of other organizations user is a member of
var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(userId,
PolicyType.SingleOrg);
if (anySingleOrgPolicies)
{
throw new BadRequestException("You cannot restore this user because they are a member of " +
"another organization which forbids it");
}
// Enforce Two Factor Authentication Policy of organization user is trying to join
if (!userHasTwoFactorEnabled)
{
var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(userId,
PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited);
if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId))
{
throw new BadRequestException("You cannot restore this user until they enable " +
"two-step login on their user account.");
}
}
}
static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(OrganizationUser organizationUser)
{
// Determine status to revert back to

View File

@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -24,6 +25,8 @@ public class PolicyService : IPolicyService
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IMailService _mailService;
private readonly GlobalSettings _globalSettings;
private readonly IFeatureService _featureService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
public PolicyService(
IApplicationCacheService applicationCacheService,
@ -33,7 +36,9 @@ public class PolicyService : IPolicyService
IPolicyRepository policyRepository,
ISsoConfigRepository ssoConfigRepository,
IMailService mailService,
GlobalSettings globalSettings)
GlobalSettings globalSettings,
IFeatureService featureService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
{
_applicationCacheService = applicationCacheService;
_eventService = eventService;
@ -43,6 +48,8 @@ public class PolicyService : IPolicyService
_ssoConfigRepository = ssoConfigRepository;
_mailService = mailService;
_globalSettings = globalSettings;
_featureService = featureService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
}
public async Task SaveAsync(Policy policy, IUserService userService, IOrganizationService organizationService,
@ -81,6 +88,12 @@ public class PolicyService : IPolicyService
return;
}
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
{
await EnablePolicy_vNext(policy, org, organizationService, savingUserId);
return;
}
await EnablePolicy(policy, org, userService, organizationService, savingUserId);
return;
}
@ -261,8 +274,7 @@ public class PolicyService : IPolicyService
var currentPolicy = await _policyRepository.GetByIdAsync(policy.Id);
if (!currentPolicy?.Enabled ?? true)
{
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(
policy.OrganizationId);
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(policy.OrganizationId);
var removableOrgUsers = orgUsers.Where(ou =>
ou.Status != OrganizationUserStatusType.Invited && ou.Status != OrganizationUserStatusType.Revoked &&
ou.Type != OrganizationUserType.Owner && ou.Type != OrganizationUserType.Admin &&
@ -311,4 +323,61 @@ public class PolicyService : IPolicyService
await SetPolicyConfiguration(policy);
}
private async Task EnablePolicy_vNext(Policy policy, Organization org, IOrganizationService organizationService, Guid? savingUserId)
{
var currentPolicy = await _policyRepository.GetByIdAsync(policy.Id);
if (!currentPolicy?.Enabled ?? true)
{
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(policy.OrganizationId);
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
var removableOrgUsers = orgUsers.Where(ou =>
ou.Status != OrganizationUserStatusType.Invited && ou.Status != OrganizationUserStatusType.Revoked &&
ou.Type != OrganizationUserType.Owner && ou.Type != OrganizationUserType.Admin &&
ou.UserId != savingUserId);
switch (policy.Type)
{
case PolicyType.TwoFactorAuthentication:
// Reorder by HasMasterPassword to prioritize checking users without a master if they have 2FA enabled
foreach (var orgUser in removableOrgUsers.OrderBy(ou => ou.HasMasterPassword))
{
var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == orgUser.Id).twoFactorIsEnabled;
if (!userTwoFactorEnabled)
{
if (!orgUser.HasMasterPassword)
{
throw new BadRequestException(
"Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page.");
}
await organizationService.DeleteUserAsync(policy.OrganizationId, orgUser.Id,
savingUserId);
await _mailService.SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(
org.DisplayName(), orgUser.Email);
}
}
break;
case PolicyType.SingleOrg:
var userOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(
removableOrgUsers.Select(ou => ou.UserId.Value));
foreach (var orgUser in removableOrgUsers)
{
if (userOrgs.Any(ou => ou.UserId == orgUser.UserId
&& ou.OrganizationId != org.Id
&& ou.Status != OrganizationUserStatusType.Invited))
{
await organizationService.DeleteUserAsync(policy.OrganizationId, orgUser.Id,
savingUserId);
await _mailService.SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(
org.DisplayName(), orgUser.Email);
}
}
break;
default:
break;
}
}
await SetPolicyConfiguration(policy);
}
}