diff --git a/src/Admin/Controllers/UsersController.cs b/src/Admin/Controllers/UsersController.cs index ed162ee546..a3620d9684 100644 --- a/src/Admin/Controllers/UsersController.cs +++ b/src/Admin/Controllers/UsersController.cs @@ -2,6 +2,9 @@ using Bit.Admin.Models; using Bit.Admin.Services; using Bit.Admin.Utilities; +using Bit.Core; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Repositories; using Bit.Core.Services; @@ -21,19 +24,28 @@ public class UsersController : Controller private readonly IPaymentService _paymentService; private readonly GlobalSettings _globalSettings; private readonly IAccessControlService _accessControlService; + private readonly ICurrentContext _currentContext; + private readonly IFeatureService _featureService; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; public UsersController( IUserRepository userRepository, ICipherRepository cipherRepository, IPaymentService paymentService, GlobalSettings globalSettings, - IAccessControlService accessControlService) + IAccessControlService accessControlService, + ICurrentContext currentContext, + IFeatureService featureService, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) { _userRepository = userRepository; _cipherRepository = cipherRepository; _paymentService = paymentService; _globalSettings = globalSettings; _accessControlService = accessControlService; + _currentContext = currentContext; + _featureService = featureService; + _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; } [RequirePermission(Permission.User_List_View)] @@ -51,6 +63,12 @@ public class UsersController : Controller var skip = (page - 1) * count; var users = await _userRepository.SearchAsync(email, skip, count); + + if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization)) + { + TempData["UsersTwoFactorIsEnabled"] = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(users.Select(u => u.Id)); + } + return View(new UsersModel { Items = users as List, diff --git a/src/Admin/Views/Users/Index.cshtml b/src/Admin/Views/Users/Index.cshtml index 98847d5096..7f941a819d 100644 --- a/src/Admin/Views/Users/Index.cshtml +++ b/src/Admin/Views/Users/Index.cshtml @@ -1,5 +1,6 @@ @model UsersModel @inject Bit.Core.Services.IUserService userService +@inject Bit.Core.Services.IFeatureService featureService @{ ViewData["Title"] = "Users"; } @@ -69,13 +70,28 @@ { } - @if(await userService.TwoFactorIsEnabledAsync(user)) + @if (featureService.IsEnabled(Bit.Core.FeatureFlagKeys.MembersTwoFAQueryOptimization)) { - + var usersTwoFactorIsEnabled = TempData["UsersTwoFactorIsEnabled"] as IEnumerable<(Guid userId, bool twoFactorIsEnabled)>; + @if(usersTwoFactorIsEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled) + { + + } + else + { + + } } else { - + @if(await userService.TwoFactorIsEnabledAsync(user)) + { + + } + else + { + + } } diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 3030842062..89e0c6e2f6 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -12,6 +12,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; 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.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -48,6 +49,7 @@ public class OrganizationUsersController : Controller private readonly IApplicationCacheService _applicationCacheService; private readonly IFeatureService _featureService; private readonly ISsoConfigRepository _ssoConfigRepository; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; public OrganizationUsersController( IOrganizationRepository organizationRepository, @@ -66,7 +68,8 @@ public class OrganizationUsersController : Controller IAuthorizationService authorizationService, IApplicationCacheService applicationCacheService, IFeatureService featureService, - ISsoConfigRepository ssoConfigRepository) + ISsoConfigRepository ssoConfigRepository, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -85,6 +88,7 @@ public class OrganizationUsersController : Controller _applicationCacheService = applicationCacheService; _featureService = featureService; _ssoConfigRepository = ssoConfigRepository; + _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; } [HttpGet("{id}")] @@ -126,8 +130,12 @@ public class OrganizationUsersController : Controller throw new NotFoundException(); } - var organizationUsers = await _organizationUserRepository - .GetManyDetailsByOrganizationAsync(orgId, includeGroups, includeCollections); + if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization)) + { + return await Get_vNext(orgId, includeGroups, includeCollections); + } + + var organizationUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(orgId, includeGroups, includeCollections); var responseTasks = organizationUsers .Select(async o => { @@ -332,7 +340,9 @@ public class OrganizationUsersController : Controller } var userId = _userService.GetProperUserId(User); - var results = await _organizationService.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value, + var results = _featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization) + ? await _organizationService.ConfirmUsersAsync_vNext(orgGuidId, model.ToDictionary(), userId.Value) + : await _organizationService.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value, _userService); return new ListResponseModel(results.Select(r => @@ -681,4 +691,32 @@ public class OrganizationUsersController : Controller return type; } + + private async Task> Get_vNext(Guid orgId, + bool includeGroups = false, bool includeCollections = false) + { + var organizationUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(orgId, includeGroups, includeCollections); + var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); + var responseTasks = organizationUsers + .Select(async o => + { + var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled; + var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled); + + // Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User + orgUser.Type = GetFlexibleCollectionsUserType(orgUser.Type, orgUser.Permissions); + + // Set 'Edit/Delete Assigned Collections' custom permissions to false + if (orgUser.Permissions is not null) + { + orgUser.Permissions.EditAssignedCollections = false; + orgUser.Permissions.DeleteAssignedCollections = false; + } + + return orgUser; + }); + var responses = await Task.WhenAll(responseTasks); + + return new ListResponseModel(responses); + } } diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 8258f4b546..53ae317f6e 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -2,9 +2,12 @@ using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; +using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; @@ -26,6 +29,8 @@ public class MembersController : Controller private readonly IApplicationCacheService _applicationCacheService; private readonly IPaymentService _paymentService; private readonly IOrganizationRepository _organizationRepository; + private readonly IFeatureService _featureService; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; public MembersController( IOrganizationUserRepository organizationUserRepository, @@ -37,7 +42,9 @@ public class MembersController : Controller IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand, IApplicationCacheService applicationCacheService, IPaymentService paymentService, - IOrganizationRepository organizationRepository) + IOrganizationRepository organizationRepository, + IFeatureService featureService, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) { _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; @@ -49,6 +56,8 @@ public class MembersController : Controller _applicationCacheService = applicationCacheService; _paymentService = paymentService; _organizationRepository = organizationRepository; + _featureService = featureService; + _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; } /// @@ -108,11 +117,18 @@ public class MembersController : Controller [ProducesResponseType(typeof(ListResponseModel), (int)HttpStatusCode.OK)] public async Task List() { - var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync( - _currentContext.OrganizationId.Value); + var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_currentContext.OrganizationId.Value); // TODO: Get all CollectionUser associations for the organization and marry them up here for the response. - var memberResponsesTasks = users.Select(async u => new MemberResponseModel(u, - await _userService.TwoFactorIsEnabledAsync(u), null)); + + if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization)) + { + return await List_vNext(organizationUserUserDetails); + } + + var memberResponsesTasks = organizationUserUserDetails.Select(async u => + { + return new MemberResponseModel(u, await _userService.TwoFactorIsEnabledAsync(u), null); + }); var memberResponses = await Task.WhenAll(memberResponsesTasks); var response = new ListResponseModel(memberResponses); return new JsonResult(response); @@ -252,4 +268,15 @@ public class MembersController : Controller await _organizationService.ResendInviteAsync(_currentContext.OrganizationId.Value, null, id); return new OkResult(); } + + private async Task List_vNext(ICollection organizationUserUserDetails) + { + var orgUsersTwoFactorIsEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUserUserDetails); + var memberResponses = organizationUserUserDetails.Select(u => + { + return new MemberResponseModel(u, orgUsersTwoFactorIsEnabled.FirstOrDefault(tuple => tuple.user == u).twoFactorIsEnabled, null); + }); + var response = new ListResponseModel(memberResponses); + return new JsonResult(response); + } } diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index d26ed901bf..fac18ca40d 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -53,6 +53,8 @@ public interface IOrganizationService Guid confirmingUserId, IUserService userService); Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, Guid confirmingUserId, IUserService userService); + Task>> ConfirmUsersAsync_vNext(Guid organizationId, Dictionary 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.")] diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 81fa09211f..37f376e507 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -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 _orgUserInviteTokenDataFactory; private readonly IFeatureService _featureService; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; public OrganizationService( IOrganizationRepository organizationRepository, @@ -99,7 +101,8 @@ public class OrganizationService : IOrganizationService IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IDataProtectorTokenFactory 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 ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, IUserService userService) { - var result = await ConfirmUsersAsync(organizationId, new Dictionary() { { organizationUserId, key } }, + var result = _featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization) + ? await ConfirmUsersAsync_vNext(organizationId, new Dictionary() { { organizationUserId, key } }, + confirmingUserId) + : await ConfirmUsersAsync(organizationId, new Dictionary() { { organizationUserId, key } }, confirmingUserId, userService); if (!result.Any()) @@ -1376,6 +1383,77 @@ public class OrganizationService : IOrganizationService return result; } + public async Task>> ConfirmUsersAsync_vNext(Guid organizationId, Dictionary 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>(); + } + + 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(); + var result = new List>(); + + foreach (var user in users) + { + if (!keyedFilteredUsers.ContainsKey(user.Id)) + { + continue; + } + var orgUser = keyedFilteredUsers[user.Id]; + var orgUsers = keyedOrganizationUsers.GetValueOrDefault(user.Id, new List()); + 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 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>(); 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 diff --git a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs index 38cd4f838f..6d67cba700 100644 --- a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs +++ b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs @@ -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); + } } diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs new file mode 100644 index 0000000000..203ef3accb --- /dev/null +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs @@ -0,0 +1,23 @@ +using Bit.Core.Auth.Models; + +namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; + +public interface ITwoFactorIsEnabledQuery +{ + /// + /// Returns a list of user IDs and whether two factor is enabled for each user. + /// + /// The list of user IDs to check. + Task> TwoFactorIsEnabledAsync(IEnumerable userIds); + /// + /// Returns a list of users and whether two factor is enabled for each user. + /// + /// The list of users to check. + /// The type of user in the list. Must implement . + Task> TwoFactorIsEnabledAsync(IEnumerable users) where T : ITwoFactorProvidersUser; + /// + /// Returns whether two factor is enabled for the user. + /// + /// The user to check. + Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user); +} diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs new file mode 100644 index 0000000000..bda2094f24 --- /dev/null +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs @@ -0,0 +1,123 @@ +using Bit.Core.Auth.Models; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Repositories; + +namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth; + +public class TwoFactorIsEnabledQuery : ITwoFactorIsEnabledQuery +{ + private readonly IUserRepository _userRepository; + + public TwoFactorIsEnabledQuery(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task> TwoFactorIsEnabledAsync(IEnumerable userIds) + { + var result = new List<(Guid userId, bool hasTwoFactor)>(); + if (userIds == null || !userIds.Any()) + { + return result; + } + + var userDetails = await _userRepository.GetManyWithCalculatedPremiumAsync(userIds.ToList()); + + foreach (var userDetail in userDetails) + { + var hasTwoFactor = false; + var providers = userDetail.GetTwoFactorProviders(); + if (providers != null) + { + // Get all enabled providers + var enabledProviderKeys = from provider in providers + where provider.Value?.Enabled ?? false + select provider.Key; + + // Find the first provider that is enabled and passes the premium check + hasTwoFactor = enabledProviderKeys + .Select(type => userDetail.HasPremiumAccess || !TwoFactorProvider.RequiresPremium(type)) + .FirstOrDefault(); + } + + result.Add((userDetail.Id, hasTwoFactor)); + } + + return result; + } + + public async Task> TwoFactorIsEnabledAsync(IEnumerable users) where T : ITwoFactorProvidersUser + { + var userIds = users + .Select(u => u.GetUserId()) + .Where(u => u.HasValue) + .Select(u => u.Value) + .ToList(); + + var twoFactorResults = await TwoFactorIsEnabledAsync(userIds); + + var result = new List<(T user, bool twoFactorIsEnabled)>(); + + foreach (var user in users) + { + var userId = user.GetUserId(); + if (userId.HasValue) + { + var hasTwoFactor = twoFactorResults.FirstOrDefault(res => res.userId == userId.Value).twoFactorIsEnabled; + result.Add((user, hasTwoFactor)); + } + else + { + result.Add((user, false)); + } + } + + return result; + } + + public async Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user) + { + var userId = user.GetUserId(); + if (!userId.HasValue) + { + return false; + } + + var providers = user.GetTwoFactorProviders(); + if (providers == null || !providers.Any()) + { + return false; + } + + // Get all enabled providers + var enabledProviderKeys = providers + .Where(provider => provider.Value?.Enabled ?? false) + .Select(provider => provider.Key); + + if (!enabledProviderKeys.Any()) + { + return false; + } + + // Determine if any enabled provider passes the premium check + var hasTwoFactor = enabledProviderKeys + .Select(type => user.GetPremium() || !TwoFactorProvider.RequiresPremium(type)) + .FirstOrDefault(); + + // If no enabled provider passes the check, check the repository for organization premium access + if (!hasTwoFactor) + { + var userDetails = await _userRepository.GetManyWithCalculatedPremiumAsync(new List { userId.Value }); + var userDetail = userDetails.FirstOrDefault(); + + if (userDetail != null) + { + hasTwoFactor = enabledProviderKeys + .Select(type => userDetail.HasPremiumAccess || !TwoFactorProvider.RequiresPremium(type)) + .FirstOrDefault(); + } + } + + return hasTwoFactor; + } +} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 15e6f5e440..2469c124b3 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -3,6 +3,8 @@ using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Auth.UserFeatures.UserKey.Implementations; using Bit.Core.Auth.UserFeatures.UserMasterPassword; @@ -24,6 +26,7 @@ public static class UserServiceCollectionExtensions services.AddUserRegistrationCommands(); services.AddWebAuthnLoginCommands(); services.AddTdeOffboardingPasswordCommands(); + services.AddTwoFactorQueries(); } public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings) @@ -54,4 +57,9 @@ public static class UserServiceCollectionExtensions services.AddScoped(); services.AddScoped(); } + + private static void AddTwoFactorQueries(this IServiceCollection services) + { + services.AddScoped(); + } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3db3b661f0..4458827804 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -135,6 +135,7 @@ public static class FeatureFlagKeys public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor"; public const string DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2"; + public const string MembersTwoFAQueryOptimization = "ac-1698-members-two-fa-query-optimization"; public static List GetAllKeys() { diff --git a/src/Core/Models/Data/UserWithCalculatedPremium.cs b/src/Core/Models/Data/UserWithCalculatedPremium.cs new file mode 100644 index 0000000000..d71e5741e2 --- /dev/null +++ b/src/Core/Models/Data/UserWithCalculatedPremium.cs @@ -0,0 +1,62 @@ +using Bit.Core.Entities; + +namespace Bit.Core.Models.Data; + +/// +/// Represents a user with an additional property indicating if the user has premium access. +/// +public class UserWithCalculatedPremium : User +{ + public UserWithCalculatedPremium() { } + + public UserWithCalculatedPremium(User user) + { + Id = user.Id; + Name = user.Name; + Email = user.Email; + EmailVerified = user.EmailVerified; + MasterPassword = user.MasterPassword; + MasterPasswordHint = user.MasterPasswordHint; + Culture = user.Culture; + SecurityStamp = user.SecurityStamp; + TwoFactorProviders = user.TwoFactorProviders; + TwoFactorRecoveryCode = user.TwoFactorRecoveryCode; + EquivalentDomains = user.EquivalentDomains; + ExcludedGlobalEquivalentDomains = user.ExcludedGlobalEquivalentDomains; + AccountRevisionDate = user.AccountRevisionDate; + Key = user.Key; + PublicKey = user.PublicKey; + PrivateKey = user.PrivateKey; + Premium = user.Premium; + PremiumExpirationDate = user.PremiumExpirationDate; + RenewalReminderDate = user.RenewalReminderDate; + Storage = user.Storage; + MaxStorageGb = user.MaxStorageGb; + Gateway = user.Gateway; + GatewayCustomerId = user.GatewayCustomerId; + GatewaySubscriptionId = user.GatewaySubscriptionId; + ReferenceData = user.ReferenceData; + LicenseKey = user.LicenseKey; + ApiKey = user.ApiKey; + Kdf = user.Kdf; + KdfIterations = user.KdfIterations; + KdfMemory = user.KdfMemory; + KdfParallelism = user.KdfParallelism; + CreationDate = user.CreationDate; + RevisionDate = user.RevisionDate; + ForcePasswordReset = user.ForcePasswordReset; + UsesKeyConnector = user.UsesKeyConnector; + FailedLoginCount = user.FailedLoginCount; + LastFailedLoginDate = user.LastFailedLoginDate; + AvatarColor = user.AvatarColor; + LastPasswordChangeDate = user.LastPasswordChangeDate; + LastKdfChangeDate = user.LastKdfChangeDate; + LastKeyRotationDate = user.LastKeyRotationDate; + LastEmailChangeDate = user.LastEmailChangeDate; + } + + /// + /// Indicates if the user has premium access, either individually or through an organization. + /// + public bool HasPremiumAccess { get; set; } +} diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 88ed4a2e25..b7c654f431 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -20,12 +20,16 @@ public interface IUserRepository : IRepository Task UpdateRenewalReminderDateAsync(Guid id, DateTime renewalReminderDate); Task> GetManyAsync(IEnumerable ids); /// + /// Retrieves the data for the requested user IDs and includes an additional property indicating + /// whether the user has premium access directly or through an organization. + /// + Task> GetManyWithCalculatedPremiumAsync(IEnumerable ids); + /// /// Sets a new user key and updates all encrypted data. /// Warning: Any user key encrypted data not included will be lost. /// /// The user to update /// Registered database calls to update re-encrypted data. - [Obsolete("Intended for future improvements to key rotation. Do not use.")] Task UpdateUserKeyAndEncryptedDataAsync(User user, IEnumerable updateDataActions); } diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 362a4da1a8..0888fb7cfc 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -65,6 +65,7 @@ public interface IUserService Task CheckPasswordAsync(User user, string password); Task CanAccessPremium(ITwoFactorProvidersUser user); Task HasPremiumFromOrganization(ITwoFactorProvidersUser user); + [Obsolete("Use ITwoFactorIsEnabledQuery instead.")] Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user); Task TwoFactorProviderIsEnabledAsync(TwoFactorProviderType provider, ITwoFactorProvidersUser user); Task GenerateSignInTokenAsync(User user, string purpose); diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index f8e7200e56..a96c986778 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Text.Json; using Bit.Core; using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Entities; @@ -255,6 +256,20 @@ public class UserRepository : Repository, IUserRepository } } + public async Task> GetManyWithCalculatedPremiumAsync(IEnumerable ids) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadByIdsWithCalculatedPremium]", + new { Ids = JsonSerializer.Serialize(ids) }, + commandType: CommandType.StoredProcedure); + + UnprotectData(results); + return results.ToList(); + } + } + private async Task ProtectDataAndSaveAsync(User user, Func saveTask) { if (user == null) diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index 0850c03706..6211d6063a 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -204,6 +204,24 @@ public class UserRepository : Repository, IUserR } } + public async Task> GetManyWithCalculatedPremiumAsync(IEnumerable ids) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var users = dbContext.Users.Where(x => ids.Contains(x.Id)); + return await users.Select(e => new DataModel.UserWithCalculatedPremium(e) + { + HasPremiumAccess = e.Premium || dbContext.OrganizationUsers + .Any(ou => ou.UserId == e.Id && + dbContext.Organizations + .Any(o => o.Id == ou.OrganizationId && + o.UsersGetPremium == true && + o.Enabled == true)) + }).ToListAsync(); + } + } + public override async Task DeleteAsync(Core.Entities.User user) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Sql/dbo/Stored Procedures/User_ReadByIdsWithCalculatedPremium.sql b/src/Sql/dbo/Stored Procedures/User_ReadByIdsWithCalculatedPremium.sql new file mode 100644 index 0000000000..a00a5fc5ec --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_ReadByIdsWithCalculatedPremium.sql @@ -0,0 +1,41 @@ +CREATE PROCEDURE [dbo].[User_ReadByIdsWithCalculatedPremium] + @Ids NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON; + + -- Declare a table variable to hold the parsed JSON data + DECLARE @ParsedIds TABLE (Id UNIQUEIDENTIFIER); + + -- Parse the JSON input into the table variable + INSERT INTO @ParsedIds (Id) + SELECT value + FROM OPENJSON(@Ids); + + -- Check if the input table is empty + IF (SELECT COUNT(1) FROM @ParsedIds) < 1 + BEGIN + RETURN(-1); + END + + -- Main query to fetch user details and calculate premium access + SELECT + U.*, + CASE + WHEN U.[Premium] = 1 + OR EXISTS ( + SELECT 1 + FROM [dbo].[OrganizationUser] OU + JOIN [dbo].[Organization] O ON OU.[OrganizationId] = O.[Id] + WHERE OU.[UserId] = U.[Id] + AND O.[UsersGetPremium] = 1 + AND O.[Enabled] = 1 + ) + THEN 1 + ELSE 0 + END AS HasPremiumAccess + FROM + [dbo].[UserView] U + WHERE + U.[Id] IN (SELECT [Id] FROM @ParsedIds); +END; diff --git a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs index e36f172210..66a59ef23e 100644 --- a/test/Api.Test/Vault/Controllers/SyncControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/SyncControllerTests.cs @@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -356,7 +357,7 @@ public class SyncControllerTests } await userService.ReceivedWithAnyArgs(1) - .TwoFactorIsEnabledAsync(default); + .TwoFactorIsEnabledAsync(default(ITwoFactorProvidersUser)); await userService.ReceivedWithAnyArgs(1) .HasPremiumFromOrganization(default); } diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 0ac77e5a3a..a8078ed015 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -7,9 +7,11 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; 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; @@ -1630,6 +1632,68 @@ OrganizationUserInvite invite, SutProvider sutProvider) await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService); } + [Theory, BitAutoData] + public async Task ConfirmUser_vNext_TwoFactorPolicy_NotEnabled_Throws(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, UserWithCalculatedPremium user, + OrganizationUser orgUserAnotherOrg, + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, + string key, SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization).Returns(true); + + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + var userService = Substitute.For(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyWithCalculatedPremiumAsync(default).ReturnsForAnyArgs(new[] { user }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); + twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, false) }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService)); + Assert.Contains("User does not have two-step login enabled.", exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUser_vNext_TwoFactorPolicy_Enabled_Success(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, UserWithCalculatedPremium user, + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, + string key, SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization).Returns(true); + + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + var userService = Substitute.For(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyWithCalculatedPremiumAsync(default).ReturnsForAnyArgs(new[] { user }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); + twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, true) }); + + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService); + } + [Theory, BitAutoData] public async Task ConfirmUsers_Success(Organization org, OrganizationUser confirmingUser, @@ -1675,6 +1739,56 @@ OrganizationUserInvite invite, SutProvider sutProvider) Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", result[2].Item2); } + [Theory, BitAutoData] + public async Task ConfirmUsers_vNext_Success(Organization org, + OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3, + OrganizationUser anotherOrgUser, UserWithCalculatedPremium user1, UserWithCalculatedPremium user2, UserWithCalculatedPremium user3, + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, + [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + var userService = Substitute.For(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser1.UserId = user1.Id; + orgUser2.UserId = user2.Id; + orgUser3.UserId = user3.Id; + anotherOrgUser.UserId = user3.Id; + var orgUsers = new[] { orgUser1, orgUser2, orgUser3 }; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(orgUsers); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyWithCalculatedPremiumAsync(default).ReturnsForAnyArgs(new[] { user1, user2, user3 }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(Arg.Any(), PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); + twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id) && ids.Contains(user3.Id))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() + { + (user1.Id, true), + (user2.Id, false), + (user3.Id, true) + }); + singleOrgPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user3.Id, PolicyType.SingleOrg) + .Returns(new[] { singleOrgPolicy }); + organizationUserRepository.GetManyByManyUsersAsync(default) + .ReturnsForAnyArgs(new[] { orgUser1, orgUser2, orgUser3, anotherOrgUser }); + + var keys = orgUsers.ToDictionary(ou => ou.Id, _ => key); + var result = await sutProvider.Sut.ConfirmUsersAsync_vNext(confirmingUser.OrganizationId, keys, confirmingUser.Id); + Assert.Contains("", result[0].Item2); + Assert.Contains("User does not have two-step login enabled.", result[1].Item2); + Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", result[2].Item2); + } + [Theory, BitAutoData] public async Task UpdateOrganizationKeysAsync_WithoutManageResetPassword_Throws(Guid orgId, string publicKey, string privateKey, SutProvider sutProvider) @@ -1842,15 +1956,17 @@ OrganizationUserInvite invite, SutProvider sutProvider) await applicationCacheService.DidNotReceiveWithAnyArgs().DeleteOrganizationAbilityAsync(default); } - private void RestoreRevokeUser_Setup(Organization organization, OrganizationUser owner, OrganizationUser organizationUser, SutProvider sutProvider) + private void RestoreRevokeUser_Setup(Organization organization, OrganizationUser restoringUser, + OrganizationUser organizationUser, SutProvider sutProvider, + OrganizationUserType restoringUserType = OrganizationUserType.Owner) { sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency().GetByIdAsync(organizationUser.OrganizationId).Returns(organization); sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); var organizationUserRepository = sutProvider.GetDependency(); - organizationUserRepository.GetManyByOrganizationAsync(organizationUser.OrganizationId, OrganizationUserType.Owner) - .Returns(new[] { owner }); + organizationUserRepository.GetManyByOrganizationAsync(organizationUser.OrganizationId, restoringUserType) + .Returns(new[] { restoringUser }); } [Theory, BitAutoData] @@ -1915,6 +2031,320 @@ OrganizationUserInvite invite, SutProvider sutProvider) .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, eventSystemUser); } + [Theory, BitAutoData] + public async Task RestoreUser_RestoreThemselves_Fails(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider sutProvider) + { + organizationUser.UserId = owner.Id; + RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); + var userService = Substitute.For(); + var organizationUserRepository = sutProvider.GetDependency(); + var eventService = sutProvider.GetDependency(); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, userService)); + + Assert.Contains("you cannot restore yourself", exception.Message.ToLowerInvariant()); + + await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); + await eventService.DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Custom)] + public async Task RestoreUser_AdminRestoreOwner_Fails(OrganizationUserType restoringUserType, Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed)] OrganizationUser restoringUser, + [OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.Owner)] OrganizationUser organizationUser, SutProvider sutProvider) + { + restoringUser.Type = restoringUserType; + RestoreRevokeUser_Setup(organization, restoringUser, organizationUser, sutProvider, OrganizationUserType.Admin); + var userService = Substitute.For(); + var organizationUserRepository = sutProvider.GetDependency(); + var eventService = sutProvider.GetDependency(); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, restoringUser.Id, userService)); + + Assert.Contains("only owners can restore other owners", exception.Message.ToLowerInvariant()); + + await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); + await eventService.DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Invited)] + [BitAutoData(OrganizationUserStatusType.Accepted)] + [BitAutoData(OrganizationUserStatusType.Confirmed)] + public async Task RestoreUser_WithStatusOtherThanRevoked_Fails(OrganizationUserStatusType userStatus, Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser] OrganizationUser organizationUser, SutProvider sutProvider) + { + organizationUser.Status = userStatus; + RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); + var userService = Substitute.For(); + var organizationUserRepository = sutProvider.GetDependency(); + var eventService = sutProvider.GetDependency(); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, userService)); + + Assert.Contains("already active", exception.Message.ToLowerInvariant()); + + await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); + await eventService.DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithSingleOrgPolicyEnabled_Fails( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser secondOrganizationUser, + SutProvider sutProvider) + { + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + secondOrganizationUser.UserId = organizationUser.UserId; + RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); + var userService = Substitute.For(); + var organizationUserRepository = sutProvider.GetDependency(); + var eventService = sutProvider.GetDependency(); + + organizationUserRepository.GetManyByUserAsync(organizationUser.UserId.Value).Returns(new[] { organizationUser, secondOrganizationUser }); + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) + .Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg } }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, userService)); + + Assert.Contains("you cannot restore this user until " + + "they leave or remove all other organizations.", exception.Message.ToLowerInvariant()); + + await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); + await eventService.DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithOtherOrganizationSingleOrgPolicyEnabled_Fails( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); + var userService = Substitute.For(); + var organizationUserRepository = sutProvider.GetDependency(); + var eventService = sutProvider.GetDependency(); + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) + .Returns(true); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, userService)); + + Assert.Contains("you cannot restore this user because they are a member of " + + "another organization which forbids it", exception.Message.ToLowerInvariant()); + + await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); + await eventService.DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_With2FAPolicyEnabled_WithoutUser2FAConfigured_Fails( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organizationUser.Email = null; + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) + .Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } }); + + RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); + var userService = Substitute.For(); + var organizationUserRepository = sutProvider.GetDependency(); + var eventService = sutProvider.GetDependency(); + + + userService.TwoFactorIsEnabledAsync(Arg.Any()).Returns(false); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, userService)); + + Assert.Contains("you cannot restore this user until they enable " + + "two-step login on their user account.", exception.Message.ToLowerInvariant()); + + await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); + await eventService.DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_With2FAPolicyEnabled_WithUser2FAConfigured_Success( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); + var userService = Substitute.For(); + var organizationUserRepository = sutProvider.GetDependency(); + var eventService = sutProvider.GetDependency(); + + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) + .Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } }); + userService.TwoFactorIsEnabledAsync(Arg.Any()).Returns(true); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, userService); + + await organizationUserRepository.Received().RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); + await eventService.Received() + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + } + + [Theory, BitAutoData] + public async Task RestoreUser_vNext_WithSingleOrgPolicyEnabled_Fails( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser secondOrganizationUser, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization).Returns(true); + + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + secondOrganizationUser.UserId = organizationUser.UserId; + RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); + var userService = Substitute.For(); + var organizationUserRepository = sutProvider.GetDependency(); + var eventService = sutProvider.GetDependency(); + + organizationUserRepository.GetManyByUserAsync(organizationUser.UserId.Value).Returns(new[] { organizationUser, secondOrganizationUser }); + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) + .Returns(new[] + { + new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg, OrganizationUserStatus = OrganizationUserStatusType.Revoked } + }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, userService)); + + Assert.Contains("you cannot restore this user until " + + "they leave or remove all other organizations.", exception.Message.ToLowerInvariant()); + + await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); + await eventService.DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_vNext_WithOtherOrganizationSingleOrgPolicyEnabled_Fails( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser secondOrganizationUser, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization).Returns(true); + + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + secondOrganizationUser.UserId = organizationUser.UserId; + RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); + var userService = Substitute.For(); + var organizationUserRepository = sutProvider.GetDependency(); + var eventService = sutProvider.GetDependency(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); + + twoFactorIsEnabledQuery + .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, true) }); + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) + .Returns(true); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, userService)); + + Assert.Contains("you cannot restore this user because they are a member of " + + "another organization which forbids it", exception.Message.ToLowerInvariant()); + + await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); + await eventService.DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_vNext_With2FAPolicyEnabled_WithoutUser2FAConfigured_Fails( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization).Returns(true); + + organizationUser.Email = null; + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) + .Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } }); + + RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); + var userService = Substitute.For(); + var organizationUserRepository = sutProvider.GetDependency(); + var eventService = sutProvider.GetDependency(); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, userService)); + + Assert.Contains("you cannot restore this user until they enable " + + "two-step login on their user account.", exception.Message.ToLowerInvariant()); + + await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); + await eventService.DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_vNext_With2FAPolicyEnabled_WithUser2FAConfigured_Success( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization).Returns(true); + + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); + var userService = Substitute.For(); + var organizationUserRepository = sutProvider.GetDependency(); + var eventService = sutProvider.GetDependency(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); + + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) + .Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } }); + + twoFactorIsEnabledQuery + .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, true) }); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, userService); + + await organizationUserRepository.Received().RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); + await eventService.Received() + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + } + [Theory, BitAutoData] public async Task HasConfirmedOwnersExcept_WithConfirmedOwner_ReturnsTrue(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, SutProvider sutProvider) { diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs new file mode 100644 index 0000000000..8011c52ead --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs @@ -0,0 +1,337 @@ +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; +using Bit.Core.Entities; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.TwoFactorAuth; + +[SutProviderCustomize] +public class TwoFactorIsEnabledQueryTests +{ + [Theory] + [BitAutoData(TwoFactorProviderType.Authenticator)] + [BitAutoData(TwoFactorProviderType.Email)] + [BitAutoData(TwoFactorProviderType.Remember)] + [BitAutoData(TwoFactorProviderType.OrganizationDuo)] + [BitAutoData(TwoFactorProviderType.WebAuthn)] + public async Task TwoFactorIsEnabledQuery_WithProviderTypeNotRequiringPremium_ReturnsAllTwoFactorEnabled( + TwoFactorProviderType freeProviderType, + SutProvider sutProvider, + List usersWithCalculatedPremium) + { + // Arrange + var userIds = usersWithCalculatedPremium.Select(u => u.Id).ToList(); + var twoFactorProviders = new Dictionary + { + { freeProviderType, new TwoFactorProvider { Enabled = true } } // Does not require premium + }; + + foreach (var user in usersWithCalculatedPremium) + { + user.HasPremiumAccess = false; + user.SetTwoFactorProviders(twoFactorProviders); + } + + sutProvider.GetDependency() + .GetManyWithCalculatedPremiumAsync(Arg.Is>(i => i.All(userIds.Contains))) + .Returns(usersWithCalculatedPremium); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(userIds); + + // Assert + foreach (var userDetail in usersWithCalculatedPremium) + { + Assert.Contains(result, res => res.userId == userDetail.Id && res.twoFactorIsEnabled == true); + } + } + + [Theory] + [BitAutoData] + public async Task TwoFactorIsEnabledQuery_WithNoTwoFactorEnabled_ReturnsAllTwoFactorDisabled( + SutProvider sutProvider, + List usersWithCalculatedPremium) + { + // Arrange + var userIds = usersWithCalculatedPremium.Select(u => u.Id).ToList(); + var twoFactorProviders = new Dictionary + { + { TwoFactorProviderType.Email, new TwoFactorProvider { Enabled = false } } + }; + + foreach (var user in usersWithCalculatedPremium) + { + user.SetTwoFactorProviders(twoFactorProviders); + } + + sutProvider.GetDependency() + .GetManyWithCalculatedPremiumAsync(Arg.Is>(i => i.All(userIds.Contains))) + .Returns(usersWithCalculatedPremium); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(userIds); + + // Assert + foreach (var userDetail in usersWithCalculatedPremium) + { + Assert.Contains(result, res => res.userId == userDetail.Id && res.twoFactorIsEnabled == false); + } + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Duo)] + [BitAutoData(TwoFactorProviderType.YubiKey)] + public async Task TwoFactorIsEnabledQuery_WithProviderTypeRequiringPremium_ReturnsMixedResults( + TwoFactorProviderType premiumProviderType, + SutProvider sutProvider, + List usersWithCalculatedPremium) + { + // Arrange + var userIds = usersWithCalculatedPremium.Select(u => u.Id).ToList(); + var twoFactorProviders = new Dictionary + { + { TwoFactorProviderType.Email, new TwoFactorProvider { Enabled = false } }, + { premiumProviderType, new TwoFactorProvider { Enabled = true } } + }; + + foreach (var user in usersWithCalculatedPremium) + { + user.HasPremiumAccess = usersWithCalculatedPremium.IndexOf(user) == 0; // Only the first user has premium access + user.SetTwoFactorProviders(twoFactorProviders); + } + + sutProvider.GetDependency() + .GetManyWithCalculatedPremiumAsync(Arg.Is>(i => i.All(userIds.Contains))) + .Returns(usersWithCalculatedPremium); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(userIds); + + // Assert + foreach (var userDetail in usersWithCalculatedPremium) + { + Assert.Contains(result, res => res.userId == userDetail.Id && res.twoFactorIsEnabled == userDetail.HasPremiumAccess); + } + } + + [Theory] + [BitAutoData] + public async Task TwoFactorIsEnabledQuery_WithNullTwoFactorProviders_ReturnsAllTwoFactorDisabled( + SutProvider sutProvider, + List usersWithCalculatedPremium) + { + // Arrange + var userIds = usersWithCalculatedPremium.Select(u => u.Id).ToList(); + + foreach (var user in usersWithCalculatedPremium) + { + user.TwoFactorProviders = null; // No two-factor providers configured + } + + sutProvider.GetDependency() + .GetManyWithCalculatedPremiumAsync(Arg.Is>(i => i.All(userIds.Contains))) + .Returns(usersWithCalculatedPremium); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(userIds); + + // Assert + foreach (var userDetail in usersWithCalculatedPremium) + { + Assert.Contains(result, res => res.userId == userDetail.Id && res.twoFactorIsEnabled == false); + } + } + + [Theory] + [BitAutoData] + public async Task TwoFactorIsEnabledQuery_WithNoUserIds_ReturnsAllTwoFactorDisabled( + SutProvider sutProvider, + List users) + { + // Arrange + foreach (var user in users) + { + user.UserId = null; + } + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(users); + + // Assert + foreach (var user in users) + { + Assert.Contains(result, res => res.user.Equals(user) && res.twoFactorIsEnabled == false); + } + + // No UserIds were supplied so no calls to the UserRepository should have been made + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetManyWithCalculatedPremiumAsync(default); + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Authenticator)] + [BitAutoData(TwoFactorProviderType.Email)] + [BitAutoData(TwoFactorProviderType.Remember)] + [BitAutoData(TwoFactorProviderType.OrganizationDuo)] + [BitAutoData(TwoFactorProviderType.WebAuthn)] + public async Task TwoFactorIsEnabledQuery_WithProviderTypeNotRequiringPremium_ReturnsTrue( + TwoFactorProviderType freeProviderType, + SutProvider sutProvider, + User user) + { + // Arrange + var twoFactorProviders = new Dictionary + { + { freeProviderType, new TwoFactorProvider { Enabled = true } } + }; + + user.Premium = false; + user.SetTwoFactorProviders(twoFactorProviders); + + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(user); + + // Assert + Assert.True(result); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetManyWithCalculatedPremiumAsync(default); + } + + [Theory] + [BitAutoData] + public async Task TwoFactorIsEnabledQuery_WithNoTwoFactorEnabled_ReturnsFalse( + SutProvider sutProvider, + User user) + { + // Arrange + var twoFactorProviders = new Dictionary + { + { TwoFactorProviderType.Email, new TwoFactorProvider { Enabled = false } } + }; + + user.SetTwoFactorProviders(twoFactorProviders); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(user); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetManyWithCalculatedPremiumAsync(default); + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Duo)] + [BitAutoData(TwoFactorProviderType.YubiKey)] + public async Task TwoFactorIsEnabledQuery_WithProviderTypeRequiringPremium_WithoutPremium_ReturnsFalse( + TwoFactorProviderType premiumProviderType, + SutProvider sutProvider, + UserWithCalculatedPremium user) + { + // Arrange + var twoFactorProviders = new Dictionary + { + { premiumProviderType, new TwoFactorProvider { Enabled = true } } + }; + + user.Premium = false; + user.HasPremiumAccess = false; + user.SetTwoFactorProviders(twoFactorProviders); + + sutProvider.GetDependency() + .GetManyWithCalculatedPremiumAsync(Arg.Is>(i => i.Contains(user.Id))) + .Returns(new List { user }); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(user); + + // Assert + Assert.False(result); + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Duo)] + [BitAutoData(TwoFactorProviderType.YubiKey)] + public async Task TwoFactorIsEnabledQuery_WithProviderTypeRequiringPremium_WithUserPremium_ReturnsTrue( + TwoFactorProviderType premiumProviderType, + SutProvider sutProvider, + User user) + { + // Arrange + var twoFactorProviders = new Dictionary + { + { premiumProviderType, new TwoFactorProvider { Enabled = true } } + }; + + user.Premium = true; + user.SetTwoFactorProviders(twoFactorProviders); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(user); + + // Assert + Assert.True(result); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetManyWithCalculatedPremiumAsync(default); + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Duo)] + [BitAutoData(TwoFactorProviderType.YubiKey)] + public async Task TwoFactorIsEnabledQuery_WithProviderTypeRequiringPremium_WithOrgPremium_ReturnsTrue( + TwoFactorProviderType premiumProviderType, + SutProvider sutProvider, + UserWithCalculatedPremium user) + { + // Arrange + var twoFactorProviders = new Dictionary + { + { premiumProviderType, new TwoFactorProvider { Enabled = true } } + }; + + user.Premium = false; + user.HasPremiumAccess = true; + user.SetTwoFactorProviders(twoFactorProviders); + + sutProvider.GetDependency() + .GetManyWithCalculatedPremiumAsync(Arg.Is>(i => i.Contains(user.Id))) + .Returns(new List { user }); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(user); + + // Assert + Assert.True(result); + } + + [Theory] + [BitAutoData] + public async Task TwoFactorIsEnabledQuery_WithNullTwoFactorProviders_ReturnsFalse( + SutProvider sutProvider, + User user) + { + // Arrange + user.TwoFactorProviders = null; // No two-factor providers configured + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(user); + + // Assert + Assert.False(result); + } +} diff --git a/util/Migrator/DbScripts/2024-08-02_00_UserReadByIdsWithCalculatedPremium.sql b/util/Migrator/DbScripts/2024-08-02_00_UserReadByIdsWithCalculatedPremium.sql new file mode 100644 index 0000000000..fc8e3d7ed1 --- /dev/null +++ b/util/Migrator/DbScripts/2024-08-02_00_UserReadByIdsWithCalculatedPremium.sql @@ -0,0 +1,41 @@ +CREATE OR ALTER PROCEDURE [dbo].[User_ReadByIdsWithCalculatedPremium] + @Ids NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON; + + -- Declare a table variable to hold the parsed JSON data + DECLARE @ParsedIds TABLE (Id UNIQUEIDENTIFIER); + + -- Parse the JSON input into the table variable + INSERT INTO @ParsedIds (Id) + SELECT value + FROM OPENJSON(@Ids); + + -- Check if the input table is empty + IF (SELECT COUNT(1) FROM @ParsedIds) < 1 + BEGIN + RETURN(-1); + END + + -- Main query to fetch user details and calculate premium access + SELECT + U.*, + CASE + WHEN U.[Premium] = 1 + OR EXISTS ( + SELECT 1 + FROM [dbo].[OrganizationUser] OU + JOIN [dbo].[Organization] O ON OU.[OrganizationId] = O.[Id] + WHERE OU.[UserId] = U.[Id] + AND O.[UsersGetPremium] = 1 + AND O.[Enabled] = 1 + ) + THEN 1 + ELSE 0 + END AS HasPremiumAccess + FROM + [dbo].[UserView] U + WHERE + U.[Id] IN (SELECT [Id] FROM @ParsedIds); +END;