From ee618328c04eec189095722f7496a97f3f6e1d6c Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Thu, 2 Nov 2023 11:02:25 -0400 Subject: [PATCH] Auth/PM-3275 - Changes to support TDE User without MP being able to Set a Password + misc refactoring (#3242) * PM-3275 - Add new GetMasterPasswordPolicy endpoint which will allow authenticated clients to get an enabled MP org policy if it exists for the purposes of enforcing those policy requirements when setting a password. * PM-3275 - AccountsController.cs - PostSetPasswordAsync - (1) Convert UserService.setPasswordAsync into new SetInitialMasterPasswordCommand (2) Refactor SetInitialMasterPasswordCommand to only accept post SSO users who are in the invited state (3) Add TODOs for more cleanup work and more commands * PM-3275 - Update AccountsControllerTests.cs to add new SetInitialMasterPasswordCommand * PM-3275 - UserService.cs - Remove non implemented ChangePasswordAsync method * PM-3275 - The new SetInitialMasterPasswordCommand leveraged the OrganizationService.cs AcceptUserAsync method so while I was in here I converted the AcceptUserAsync methods into a new AcceptOrgUserCommand.cs and turned the private method which accepted an existing org user public for use in the SetInitialMasterPasswordCommand * PM-3275 - Dotnet format * PM-3275 - Test SetInitialMasterPasswordCommand * Dotnet format * PM-3275 - In process AcceptOrgUserCommandTests.cs * PM-3275 - Migrate changes from AC-244 / #3199 over into new AcceptOrgUserCommand * PM-3275 - AcceptOrgUserCommand.cs - create data protector specifically for this command * PM-3275 - Add TODO for renaming / removing overloading of methods to improve readability / clarity * PM-3275 - AcceptOrgUserCommand.cs - refactor AcceptOrgUserAsync by OrgId to retrieve orgUser with _organizationUserRepository.GetByOrganizationAsync which gets a single user instead of a collection * PM-3275 - AcceptOrgUserCommand.cs - update name in TODO for evaluation later * PM-3275 / PM-1196 - (1) Slightly refactor SsoEmail2faSessionTokenable to provide public static GetTokenLifeTime() method for testing (2) Add missed tests to SsoEmail2faSessionTokenable in preparation for building tests for new OrgUserInviteTokenable.cs * PM-3275 / PM-1196 - Removing SsoEmail2faSessionTokenable.cs changes + tests as I've handled that separately in a new PR (#3270) for newly created task PM-3925 * PM-3275 - ExpiringTokenable.cs - add clarifying comments to help distinguish between the Valid property and the TokenIsValid method. * PM-3275 - Create OrgUserInviteTokenable.cs and add tests in OrgUserInviteTokenableTests.cs * PM-3275 - OrganizationService.cs - Refactor Org User Invite methods to use new OrgUserInviteTokenable instead of manual creation of a token * PM-3275 - OrgUserInviteTokenable.cs - clarify backwards compat note * PM-3275 - AcceptOrgUserCommand.cs - Add TODOs + minor name refactor * PM-3275 - AcceptOrgUserCommand.cs - replace method overloading with more easily readable names. * PM-3275 - AcceptOrgUserCommand.cs - Update ValidateOrgUserInviteToken to add new token validation while maintaining backwards compatibility for 1 release. * dotnet format * PM-3275 - AcceptOrgUserCommand.cs - Move private method below where it is used * PM-3275 - ServiceCollectionExtensions.cs - Must register IDataProtectorTokenFactory for new tokenable * PM-3275 - OrgUserInviteTokenable needed access to global settings to set its token lifetime to the _globalSettings.OrganizationInviteExpirationHours value. Creating a factory seemed the most straightforward way to encapsulate the desired creation logic. Unsure if in the correct location in ServiceCollectionExtensions.cs but will figure that out later. * PM-3275 - In process work of creating AcceptOrgUserCommandTests.cs * PM-3275 - Remove no longer relevant AcceptOrgUser tests from OrganizationServiceTests.cs * PM-3275 - Register OrgUserInviteTokenableFactory alongside tokenizer * PM-3275 - AcceptOrgUserCommandTests.cs - AcceptOrgUserAsync basic test suite completed. * PM-3275 - AcceptOrgUserCommandTests.cs - tweak test names * PM-3275 - AcceptOrgUserCommandTests.cs - (1) Remove old tests from OrganizationServiceTests as no longer needed to reference (2) Add summary for SetupCommonAcceptOrgUserMocks (3) Get AcceptOrgUserByToken_OldToken_AcceptsUserAndVerifiesEmail passing * PM-3275 - Create interface for OrgUserInviteTokenableFactory b/c that's the right thing to do + enables test substitution * PM-3275 - AcceptOrgUserCommandTests.cs - (1) Start work on AcceptOrgUserByToken_NewToken_AcceptsUserAndVerifiesEmail (2) Create and use SetupCommonAcceptOrgUserByTokenMocks() (3) Create generic FakeDataProtectorTokenFactory for tokenable testing * PM-3275 - (1) Get AcceptOrgUserByToken_NewToken_AcceptsUserAndVerifiesEmail test passing (2) Move FakeDataProtectorTokenFactory to own file * PM-3275 - AcceptOrgUserCommandTests.cs - Finish up tests for AcceptOrgUserByTokenAsync * PM-3275 - Add pseudo section comments * PM-3275 - Clean up unused params on AcceptOrgUserByToken_EmailMismatch_ThrowsBadRequest test * PM-3275 - (1) Tests written for AcceptOrgUserByOrgSsoIdAsync (2) Refactor happy path assertions into helper function AssertValidAcceptedOrgUser to reduce code duplication * PM-3275 - Finish up testing AcceptOrgUserCommandTests.cs by adding tests for AcceptOrgUserByOrgIdAsync * PM-3275 - Tweaking test naming to ensure consistency. * PM-3275 - Bugfix - OrgUserInviteTokenableFactory implementation required when declaring singleton service in ServiceCollectionExtensions.cs * PM-3275 - Resolve failing OrganizationServiceTests.cs * dotnet format * PM-3275 - PoliciesController.cs - GetMasterPasswordPolicy bugfix - for orgs without a MP policy, policy comes back as null and we should return notFound in that case. * PM-3275 - Add PoliciesControllerTests.cs specifically for new GetMasterPasswordPolicy(...) endpoint. * PM-3275 - dotnet format PoliciesControllerTests.cs * PM-3275 - PoliciesController.cs - (1) Add tech debt task number (2) Properly flag endpoint as deprecated * PM-3275 - Add new hasManageResetPasswordPermission property to ProfileResponseModel.cs primarily for sync so that we can condition client side if TDE user obtains elevated permissions * PM-3275 - Fix AccountsControllerTests.cs * PM-3275 - OrgUserInviteTokenable.cs - clarify TODO * PM-3275 - AcceptOrgUserCommand.cs - Refactor token validation to use short circuiting to only run old token validation if new token validation fails. * PM-3275 - OrgUserInviteTokenable.cs - (1) Add new static methods to centralize validation logic to avoid repetition (2) Add new token validation method so we can avoid having to pass in a full org user (and hitting the db to do so) * PM-3275 - Realized that the old token validation was used in the PoliciesController.cs (existing user clicks invite link in email and goes to log in) and UserService.cs (user clicks invite link in email and registers for a new acct). Added tech debt item for cleaning up backwards compatibility in future. * dotnet format * PM-3275 - (1) AccountsController.cs - Update PostSetPasswordAsync SetPasswordRequestModel to allow null keys for the case where we have a TDE user who obtains elevated permissions - they already have a user public and user encrypted private key saved in the db. (2) AccountsControllerTests.cs - test PostSetPasswordAsync scenarios to ensure changes will work as expected. * PM-3275 - PR review feedback - (1) set CurrentContext to private (2) Refactor GetProfile to use variables to improve clarity and simplify debugging. * PM-3275 - SyncController.cs - PR Review Feedback - Set current context as private instead of protected. * PM-3275 - CurrentContextExtensions.cs - PR Feedback - move parenthesis up from own line. * PM-3275 - SetInitialMasterPasswordCommandTests.cs - Replace unnecessary variable * PM-3275 - SetInitialMasterPasswordCommandTests.cs - PR Feedback - Add expected outcome statement to test name * PM-3275 - Set Initial Password command and tests - PR Feedback changes - (1) Rename orgIdentifier --> OrgSsoIdentifier for clarity (2) Update SetInitialMasterPasswordAsync to not allow null orgSsoId with explicit message saying this vs letting null org trigger invalid organization (3) Add test to cover this new scenario. * PM-3275 - SetInitialMasterPasswordCommand.cs - Move summary from implementation to interface to better respect standards and the fact that the interface is the more seen piece of code. * PM-3275 - AcceptOrgUserCommand.cs - Per PR feedback, rename AcceptOrgUserByTokenAsync -> AcceptOrgUserByEmailTokenAsync + replace generic name token with emailToken * PM-3275 - OrganizationService.cs - Per PR feedback, remove dupe line * PM-3275 - AcceptOrgUserCommand.cs - Per PR feedback, remove new lines in error messages for consistency. * PM-3275 - SetInitialMasterPasswordCommand.cs - Per PR feedback, adjust formatting of constructor for improved readability. * PM-3275 - CurrentContextExtensions.cs - Refactor AnyOrgUserHasManageResetPasswordPermission per PR feedback to remove unnecessary var. * PM-3275 - AcceptOrgUserCommand.cs - Per PR feedback, remove completed TODO * PM-3275 - PoliciesController.cs - Per PR feedback, update GetByInvitedUser param to be guid instead of string. * PM-3275 - OrgUserInviteTokenable.cs - per PR feedback, add tech debt item info. * PM-3275 - AcceptOrgUserCommand.cs - Per PR feedback, use const purpose from tokenable instead of magic string. * PM-3275 - Restore non duplicate line to fix tests * PM-3275 - Per PR feedback, revert all sync controller changes as the ProfileResponseModel.organizations array has org objects which have permissions which have the ManageResetPassword permission. So, I have the information that I need clientside already to determine if the user has the ManageResetPassword in any org. * PM-3275 - PoliciesControllerTests.cs - Update imports as the PoliciesController was moved under the admin console team's domain. * PM-3275 - Resolve issues from merge conflict resolutions to get solution building. * PM-3275 / PM-4633 - PoliciesController.cs - use orgUserId to look up user instead of orgId. Oops. * Fix user service tests * Resolve merge conflict --- .../OrganizationUsersController.cs | 12 +- .../Controllers/PoliciesController.cs | 63 +- .../Accounts/SetPasswordRequestModel.cs | 9 +- src/Api/Controllers/AccountsController.cs | 21 +- src/Api/Vault/Controllers/SyncController.cs | 1 + .../IOrgUserInviteTokenableFactory.cs | 8 + .../Tokenables/OrgUserInviteTokenable.cs | 84 +++ .../OrgUserInviteTokenableFactory.cs | 23 + .../Tokenables/SsoEmail2faSessionTokenable.cs | 5 - .../ISetInitialMasterPasswordCommand.cs | 19 + .../SetInitialMasterPasswordCommand.cs | 103 +++ .../UserServiceCollectionExtensions.cs | 24 + ...OrganizationServiceCollectionExtensions.cs | 3 + .../OrganizationUsers/AcceptOrgUserCommand.cs | 220 ++++++ .../Interfaces/IAcceptOrgUserCommand.cs | 18 + src/Core/Services/IOrganizationService.cs | 9 - src/Core/Services/IUserService.cs | 4 +- .../Implementations/OrganizationService.cs | 199 +----- .../Services/Implementations/UserService.cs | 62 +- src/Core/Tokens/ExpiringTokenable.cs | 9 + src/Core/Utilities/CoreHelpers.cs | 1 + .../Utilities/ServiceCollectionExtensions.cs | 13 +- .../OrganizationUsersControllerTests.cs | 13 +- .../Controllers/AccountsControllerTests.cs | 126 +++- .../Controllers/PoliciesControllerTests.cs | 133 ++++ .../Fakes/FakeDataProtectorTokenFactory.cs | 55 ++ .../Tokenables/OrgUserInviteTokenableTests.cs | 264 +++++++ .../SetInitialMasterPasswordCommandTests.cs | 193 +++++ .../AcceptOrgUserCommandTests.cs | 664 ++++++++++++++++++ .../Services/OrganizationServiceTests.cs | 112 ++- test/Core.Test/Services/UserServiceTests.cs | 5 +- 31 files changed, 2173 insertions(+), 302 deletions(-) create mode 100644 src/Core/Auth/Models/Business/Tokenables/IOrgUserInviteTokenableFactory.cs create mode 100644 src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs create mode 100644 src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenableFactory.cs create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs create mode 100644 src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs create mode 100644 src/Core/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs create mode 100644 src/Core/OrganizationFeatures/OrganizationUsers/Interfaces/IAcceptOrgUserCommand.cs create mode 100644 test/Api.Test/Controllers/PoliciesControllerTests.cs create mode 100644 test/Common/Fakes/FakeDataProtectorTokenFactory.cs create mode 100644 test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs create mode 100644 test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandTests.cs create mode 100644 test/Core.Test/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 6354df669..73140cea4 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -11,6 +11,7 @@ using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.Policies; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; @@ -33,6 +34,7 @@ public class OrganizationUsersController : Controller private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand; + private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; public OrganizationUsersController( IOrganizationRepository organizationRepository, @@ -45,7 +47,8 @@ public class OrganizationUsersController : Controller ICurrentContext currentContext, ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, - IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand) + IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand, + IAcceptOrgUserCommand acceptOrgUserCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -58,6 +61,7 @@ public class OrganizationUsersController : Controller _countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand; + _acceptOrgUserCommand = acceptOrgUserCommand; } [HttpGet("{id}")] @@ -199,7 +203,7 @@ public class OrganizationUsersController : Controller } await _organizationService.InitPendingOrganization(user.Id, orgId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName); - await _organizationService.AcceptUserAsync(organizationUserId, user, model.Token, _userService); + await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService); await _organizationService.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id, _userService); } @@ -221,7 +225,7 @@ public class OrganizationUsersController : Controller throw new BadRequestException(string.Empty, "Master Password reset is required, but not provided."); } - await _organizationService.AcceptUserAsync(organizationUserId, user, model.Token, _userService); + await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService); if (useMasterPasswordPolicy) { @@ -332,7 +336,7 @@ public class OrganizationUsersController : Controller var orgUser = await _organizationUserRepository.GetByOrganizationAsync(orgId, user.Id); if (orgUser.Status == OrganizationUserStatusType.Invited) { - await _organizationService.AcceptUserAsync(orgId, user, _userService); + await _acceptOrgUserCommand.AcceptOrgUserByOrgIdAsync(orgId, user, _userService); } } diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index 6f93c22de..60257814e 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -1,5 +1,6 @@ using Bit.Api.AdminConsole.Models.Request; using Bit.Api.Models.Response; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -7,6 +8,7 @@ using Bit.Core.Models.Api.Response; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Tokens; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; @@ -26,6 +28,7 @@ public class PoliciesController : Controller private readonly ICurrentContext _currentContext; private readonly GlobalSettings _globalSettings; private readonly IDataProtector _organizationServiceDataProtector; + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; public PoliciesController( IPolicyRepository policyRepository, @@ -35,7 +38,8 @@ public class PoliciesController : Controller IUserService userService, ICurrentContext currentContext, GlobalSettings globalSettings, - IDataProtectionProvider dataProtectionProvider) + IDataProtectionProvider dataProtectionProvider, + IDataProtectorTokenFactory orgUserInviteTokenDataFactory) { _policyRepository = policyRepository; _policyService = policyService; @@ -46,6 +50,8 @@ public class PoliciesController : Controller _globalSettings = globalSettings; _organizationServiceDataProtector = dataProtectionProvider.CreateProtector( "OrganizationServiceDataProtector"); + + _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; } [HttpGet("{type}")] @@ -81,41 +87,46 @@ public class PoliciesController : Controller [AllowAnonymous] [HttpGet("token")] - public async Task> GetByToken(string orgId, [FromQuery] string email, - [FromQuery] string token, [FromQuery] string organizationUserId) + public async Task> GetByToken(Guid orgId, [FromQuery] string email, + [FromQuery] string token, [FromQuery] Guid organizationUserId) { - var orgUserId = new Guid(organizationUserId); - var tokenValid = CoreHelpers.UserInviteTokenIsValid(_organizationServiceDataProtector, token, - email, orgUserId, _globalSettings); + // TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete + var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( + _orgUserInviteTokenDataFactory, token, organizationUserId, email); + + var tokenValid = newTokenValid || CoreHelpers.UserInviteTokenIsValid( + _organizationServiceDataProtector, token, email, organizationUserId, _globalSettings + ); + if (!tokenValid) { throw new NotFoundException(); } - var orgIdGuid = new Guid(orgId); - var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId); - if (orgUser == null || orgUser.OrganizationId != orgIdGuid) + var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); + if (orgUser == null || orgUser.OrganizationId != orgId) { throw new NotFoundException(); } - var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid); + var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgId); var responses = policies.Where(p => p.Enabled).Select(p => new PolicyResponseModel(p)); return new ListResponseModel(responses); } + // TODO: PM-4097 - remove GetByInvitedUser once all clients are updated to use the GetMasterPasswordPolicy endpoint below + [Obsolete("Deprecated API", false)] [AllowAnonymous] [HttpGet("invited-user")] - public async Task> GetByInvitedUser(string orgId, [FromQuery] string userId) + public async Task> GetByInvitedUser(Guid orgId, [FromQuery] Guid userId) { - var user = await _userService.GetUserByIdAsync(new Guid(userId)); + var user = await _userService.GetUserByIdAsync(userId); if (user == null) { throw new UnauthorizedAccessException(); } - var orgIdGuid = new Guid(orgId); var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(user.Id); - var orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgIdGuid); + var orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId); if (orgUser == null) { throw new NotFoundException(); @@ -125,11 +136,33 @@ public class PoliciesController : Controller throw new UnauthorizedAccessException(); } - var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid); + var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgId); var responses = policies.Where(p => p.Enabled).Select(p => new PolicyResponseModel(p)); return new ListResponseModel(responses); } + [HttpGet("master-password")] + public async Task GetMasterPasswordPolicy(Guid orgId) + { + var userId = _userService.GetProperUserId(User).Value; + + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(orgId, userId); + + if (orgUser == null) + { + throw new NotFoundException(); + } + + var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.MasterPassword); + + if (policy == null || !policy.Enabled) + { + throw new NotFoundException(); + } + + return new PolicyResponseModel(policy); + } + [HttpPut("{type}")] public async Task Put(string orgId, int type, [FromBody] PolicyRequestModel model) { diff --git a/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs index 9c078d7b2..6339725cd 100644 --- a/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs @@ -1,4 +1,6 @@ -using System.ComponentModel.DataAnnotations; +#nullable enable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; @@ -15,8 +17,7 @@ public class SetPasswordRequestModel : IValidatableObject public string Key { get; set; } [StringLength(50)] public string MasterPasswordHint { get; set; } - [Required] - public KeysRequestModel Keys { get; set; } + public KeysRequestModel? Keys { get; set; } [Required] public KdfType Kdf { get; set; } [Required] @@ -33,7 +34,7 @@ public class SetPasswordRequestModel : IValidatableObject existingUser.KdfMemory = KdfMemory; existingUser.KdfParallelism = KdfParallelism; existingUser.Key = Key; - Keys.ToUser(existingUser); + Keys?.ToUser(existingUser); return existingUser; } diff --git a/src/Api/Controllers/AccountsController.cs b/src/Api/Controllers/AccountsController.cs index 804aeb651..8be36522a 100644 --- a/src/Api/Controllers/AccountsController.cs +++ b/src/Api/Controllers/AccountsController.cs @@ -10,6 +10,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Api.Response.Accounts; using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Auth.Utilities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -47,6 +48,8 @@ public class AccountsController : Controller private readonly ISendService _sendService; private readonly ICaptchaValidationService _captchaValidationService; private readonly IPolicyService _policyService; + private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; + public AccountsController( GlobalSettings globalSettings, @@ -61,7 +64,9 @@ public class AccountsController : Controller ISendRepository sendRepository, ISendService sendService, ICaptchaValidationService captchaValidationService, - IPolicyService policyService) + IPolicyService policyService, + ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand + ) { _cipherRepository = cipherRepository; _folderRepository = folderRepository; @@ -76,6 +81,7 @@ public class AccountsController : Controller _sendService = sendService; _captchaValidationService = captchaValidationService; _policyService = policyService; + _setInitialMasterPasswordCommand = setInitialMasterPasswordCommand; } #region DEPRECATED (Moved to Identity Service) @@ -253,8 +259,12 @@ public class AccountsController : Controller throw new UnauthorizedAccessException(); } - var result = await _userService.SetPasswordAsync(model.ToUser(user), model.MasterPasswordHash, model.Key, + var result = await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync( + model.ToUser(user), + model.MasterPasswordHash, + model.Key, model.OrgIdentifier); + if (result.Succeeded) { return; @@ -456,8 +466,13 @@ public class AccountsController : Controller var providerUserOrganizationDetails = await _providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id, ProviderUserStatusType.Confirmed); + + var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); + var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); + var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, - providerUserOrganizationDetails, await _userService.TwoFactorIsEnabledAsync(user), await _userService.HasPremiumFromOrganization(user)); + providerUserOrganizationDetails, twoFactorEnabled, + hasPremiumFromOrg); return response; } diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index 01a1fbb55..ac7ef9acd 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -71,6 +71,7 @@ public class SyncController : Controller await _providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id, ProviderUserStatusType.Confirmed); var hasEnabledOrgs = organizationUserDetails.Any(o => o.Enabled); + var folders = await _folderRepository.GetManyByUserIdAsync(user.Id); var ciphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, hasEnabledOrgs); var sends = await _sendRepository.GetManyByUserIdAsync(user.Id); diff --git a/src/Core/Auth/Models/Business/Tokenables/IOrgUserInviteTokenableFactory.cs b/src/Core/Auth/Models/Business/Tokenables/IOrgUserInviteTokenableFactory.cs new file mode 100644 index 000000000..04d3b008b --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/IOrgUserInviteTokenableFactory.cs @@ -0,0 +1,8 @@ +using Bit.Core.Entities; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +public interface IOrgUserInviteTokenableFactory +{ + OrgUserInviteTokenable CreateToken(OrganizationUser orgUser); +} diff --git a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs new file mode 100644 index 000000000..95c84ad3b --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs @@ -0,0 +1,84 @@ +using System.Text.Json.Serialization; +using Bit.Core.Entities; +using Bit.Core.Tokens; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +public class OrgUserInviteTokenable : ExpiringTokenable +{ + // TODO: PM-4317 - Ideally this would be internal and only visible to the test project. + // but configuring that is out of scope for these changes. + public static TimeSpan GetTokenLifetime() => TimeSpan.FromDays(5); + + public const string ClearTextPrefix = "BwOrgUserInviteToken_"; + + // Backwards compatibility Note: + // Previously, tokens were manually created in the OrganizationService using a data protector + // initialized with purpose: "OrganizationServiceDataProtector" + // So, we must continue to use the existing purpose to be able to decrypt tokens + // in emailed invites that have not yet been accepted. + public const string DataProtectorPurpose = "OrganizationServiceDataProtector"; + + public const string TokenIdentifier = "OrgUserInviteToken"; + + public string Identifier { get; set; } = TokenIdentifier; + public Guid OrgUserId { get; set; } + public string OrgUserEmail { get; set; } + + [JsonConstructor] + public OrgUserInviteTokenable() + { + ExpirationDate = DateTime.UtcNow.Add(GetTokenLifetime()); + } + + public OrgUserInviteTokenable(OrganizationUser orgUser) : this() + { + OrgUserId = orgUser?.Id ?? default; + OrgUserEmail = orgUser?.Email; + } + + public bool TokenIsValid(OrganizationUser orgUser) + { + if (OrgUserId == default || OrgUserEmail == default || orgUser == null) + { + return false; + } + + return OrgUserId == orgUser.Id && + OrgUserEmail.Equals(orgUser.Email, StringComparison.InvariantCultureIgnoreCase); + } + + public bool TokenIsValid(Guid orgUserId, string orgUserEmail) + { + if (OrgUserId == default || OrgUserEmail == default || orgUserId == default || orgUserEmail == default) + { + return false; + } + + return OrgUserId == orgUserId && + OrgUserEmail.Equals(orgUserEmail, StringComparison.InvariantCultureIgnoreCase); + } + + // Validates deserialized + protected override bool TokenIsValid() => + Identifier == TokenIdentifier && OrgUserId != default && !string.IsNullOrWhiteSpace(OrgUserEmail); + + + public static bool ValidateOrgUserInviteStringToken( + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, + string orgUserInviteToken, OrganizationUser orgUser) + { + return orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken) + && decryptedToken.Valid + && decryptedToken.TokenIsValid(orgUser); + } + + public static bool ValidateOrgUserInviteStringToken( + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, + string orgUserInviteToken, Guid orgUserId, string orgUserEmail) + { + return orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken) + && decryptedToken.Valid + && decryptedToken.TokenIsValid(orgUserId, orgUserEmail); + } +} diff --git a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenableFactory.cs b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenableFactory.cs new file mode 100644 index 000000000..9e5a797db --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenableFactory.cs @@ -0,0 +1,23 @@ +using Bit.Core.Entities; +using Bit.Core.Settings; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +public class OrgUserInviteTokenableFactory : IOrgUserInviteTokenableFactory +{ + private readonly IGlobalSettings _globalSettings; + + public OrgUserInviteTokenableFactory(IGlobalSettings globalSettings) + { + _globalSettings = globalSettings; + } + + public OrgUserInviteTokenable CreateToken(OrganizationUser orgUser) + { + var token = new OrgUserInviteTokenable(orgUser) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromHours(_globalSettings.OrganizationInviteExpirationHours)) + }; + return token; + } +} diff --git a/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs index ea8347653..24a74bde0 100644 --- a/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs @@ -16,12 +16,9 @@ public class SsoEmail2faSessionTokenable : ExpiringTokenable public const string DataProtectorPurpose = "SsoEmail2faSessionTokenDataProtector"; public const string TokenIdentifier = "SsoEmail2faSessionToken"; - public string Identifier { get; set; } = TokenIdentifier; public Guid Id { get; set; } public string Email { get; set; } - - [JsonConstructor] public SsoEmail2faSessionTokenable() { @@ -33,14 +30,12 @@ public class SsoEmail2faSessionTokenable : ExpiringTokenable Id = user?.Id ?? default; Email = user?.Email; } - public bool TokenIsValid(User user) { if (Id == default || Email == default || user == null) { return false; } - return Id == user.Id && Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase); } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs new file mode 100644 index 000000000..31dd19d5b --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs @@ -0,0 +1,19 @@ +using Bit.Core.Entities; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; + +/// +/// Manages the setting of the initial master password for a in an organization. +/// This class is primarily invoked in two scenarios: +/// 1) In organizations configured with Single Sign-On (SSO) and master password decryption: +/// just in time (JIT) provisioned users logging in via SSO are required to set a master password. +/// 2) In organizations configured with SSO and trusted devices decryption: +/// Users who are upgraded to have admin account recovery permissions must set a master password +/// to ensure their ability to reset other users' accounts. +/// +public interface ISetInitialMasterPasswordCommand +{ + public Task SetInitialMasterPasswordAsync(User user, string masterPassword, string key, + string orgSsoIdentifier); +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs new file mode 100644 index 000000000..32966f571 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs @@ -0,0 +1,103 @@ +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; + +public class SetInitialMasterPasswordCommand : ISetInitialMasterPasswordCommand +{ + private readonly ILogger _logger; + private readonly IdentityErrorDescriber _identityErrorDescriber; + private readonly IUserService _userService; + private readonly IUserRepository _userRepository; + private readonly IEventService _eventService; + private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; + + + public SetInitialMasterPasswordCommand( + ILogger logger, + IdentityErrorDescriber identityErrorDescriber, + IUserService userService, + IUserRepository userRepository, + IEventService eventService, + IAcceptOrgUserCommand acceptOrgUserCommand, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository) + { + _logger = logger; + _identityErrorDescriber = identityErrorDescriber; + _userService = userService; + _userRepository = userRepository; + _eventService = eventService; + _acceptOrgUserCommand = acceptOrgUserCommand; + _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; + } + + public async Task SetInitialMasterPasswordAsync(User user, string masterPassword, string key, + string orgSsoIdentifier) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if (!string.IsNullOrWhiteSpace(user.MasterPassword)) + { + _logger.LogWarning("Change password failed for user {userId} - already has password.", user.Id); + return IdentityResult.Failed(_identityErrorDescriber.UserAlreadyHasPassword()); + } + + var result = await _userService.UpdatePasswordHash(user, masterPassword, validatePassword: true, refreshStamp: false); + if (!result.Succeeded) + { + return result; + } + + user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow; + user.Key = key; + + await _userRepository.ReplaceAsync(user); + await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); + + + if (string.IsNullOrWhiteSpace(orgSsoIdentifier)) + { + throw new BadRequestException("Organization SSO Identifier required."); + } + + var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier); + + if (org == null) + { + throw new BadRequestException("Organization invalid."); + } + + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id); + + if (orgUser == null) + { + throw new BadRequestException("User not found within organization."); + } + + // TDE users who go from a user without admin acct recovery permission to having it will be + // required to set a MP for the first time and we don't want to re-execute the accept logic + // as they are already confirmed. + // TLDR: only accept post SSO user if they are invited + if (orgUser.Status == OrganizationUserStatusType.Invited) + { + await _acceptOrgUserCommand.AcceptOrgUserAsync(orgUser, user, _userService); + } + + return IdentityResult.Success; + } + +} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs new file mode 100644 index 000000000..eff162c73 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -0,0 +1,24 @@ + + +using Bit.Core.Auth.UserFeatures.UserMasterPassword; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Services; +using Bit.Core.Settings; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Auth.UserFeatures; + +public static class UserServiceCollectionExtensions +{ + public static void AddUserServices(this IServiceCollection services, IGlobalSettings globalSettings) + { + services.AddScoped(); + services.AddUserPasswordCommands(); + } + + private static void AddUserPasswordCommands(this IServiceCollection services) + { + services.AddScoped(); + } + +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index e3a3bfeae..e70738e06 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -21,6 +21,8 @@ using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterpri using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.SelfHosted; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.OrganizationFeatures.OrganizationUsers; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; @@ -132,6 +134,7 @@ public static class OrganizationServiceCollectionExtensions private static void AddOrganizationUserCommandsQueries(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } // TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of diff --git a/src/Core/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs new file mode 100644 index 000000000..5baff5463 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -0,0 +1,220 @@ +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Tokens; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.DataProtection; + +namespace Bit.Core.OrganizationFeatures.OrganizationUsers; + +public class AcceptOrgUserCommand : IAcceptOrgUserCommand +{ + private readonly IDataProtector _dataProtector; + private readonly IGlobalSettings _globalSettings; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IPolicyService _policyService; + private readonly IMailService _mailService; + private readonly IUserRepository _userRepository; + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; + + public AcceptOrgUserCommand( + IDataProtectionProvider dataProtectionProvider, + IGlobalSettings globalSettings, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyService policyService, + IMailService mailService, + IUserRepository userRepository, + IDataProtectorTokenFactory orgUserInviteTokenDataFactory) + { + + // TODO: remove data protector when old token validation removed + _dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose); + _globalSettings = globalSettings; + _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; + _policyService = policyService; + _mailService = mailService; + _userRepository = userRepository; + _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; + } + + public async Task AcceptOrgUserByEmailTokenAsync(Guid organizationUserId, User user, string emailToken, + IUserService userService) + { + var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); + if (orgUser == null) + { + throw new BadRequestException("User invalid."); + } + + // Tokens will have been created in two ways in the OrganizationService invite methods: + // 1. New way - via OrgUserInviteTokenable + // 2. Old way - via manual process using data protector initialized with purpose: "OrganizationServiceDataProtector" + // For backwards compatibility, must check validity of both types of tokens and accept if either is valid + + // TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete + var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( + _orgUserInviteTokenDataFactory, emailToken, orgUser); + + var tokenValid = newTokenValid || + CoreHelpers.UserInviteTokenIsValid(_dataProtector, emailToken, user.Email, orgUser.Id, + _globalSettings); + + if (!tokenValid) + { + throw new BadRequestException("Invalid token."); + } + + var existingOrgUserCount = await _organizationUserRepository.GetCountByOrganizationAsync( + orgUser.OrganizationId, user.Email, true); + if (existingOrgUserCount > 0) + { + if (orgUser.Status == OrganizationUserStatusType.Accepted) + { + throw new BadRequestException("Invitation already accepted. You will receive an email when your organization membership is confirmed."); + } + throw new BadRequestException("You are already part of this organization."); + } + + if (string.IsNullOrWhiteSpace(orgUser.Email) || + !orgUser.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase)) + { + throw new BadRequestException("User email does not match invite."); + } + + var organizationUser = await AcceptOrgUserAsync(orgUser, user, userService); + + // Verify user email if they accept org invite via email link + if (user.EmailVerified == false) + { + user.EmailVerified = true; + await _userRepository.ReplaceAsync(user); + } + + return organizationUser; + } + + private bool ValidateOrgUserInviteToken(string orgUserInviteToken, OrganizationUser orgUser) + { + return _orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken) + && decryptedToken.Valid + && decryptedToken.TokenIsValid(orgUser); + } + + public async Task AcceptOrgUserByOrgSsoIdAsync(string orgSsoIdentifier, User user, IUserService userService) + { + var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier); + if (org == null) + { + throw new BadRequestException("Organization invalid."); + } + + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id); + if (orgUser == null) + { + throw new BadRequestException("User not found within organization."); + } + + return await AcceptOrgUserAsync(orgUser, user, userService); + } + + public async Task AcceptOrgUserByOrgIdAsync(Guid organizationId, User user, IUserService userService) + { + var org = await _organizationRepository.GetByIdAsync(organizationId); + if (org == null) + { + throw new BadRequestException("Organization invalid."); + } + + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id); + if (orgUser == null) + { + throw new BadRequestException("User not found within organization."); + } + + return await AcceptOrgUserAsync(orgUser, user, userService); + } + + public async Task AcceptOrgUserAsync(OrganizationUser orgUser, User user, + IUserService userService) + { + if (orgUser.Status == OrganizationUserStatusType.Revoked) + { + throw new BadRequestException("Your organization access has been revoked."); + } + + if (orgUser.Status != OrganizationUserStatusType.Invited) + { + throw new BadRequestException("Already accepted."); + } + + if (orgUser.Type == OrganizationUserType.Owner || orgUser.Type == OrganizationUserType.Admin) + { + var org = await _organizationRepository.GetByIdAsync(orgUser.OrganizationId); + if (org.PlanType == PlanType.Free) + { + var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync( + user.Id); + if (adminCount > 0) + { + throw new BadRequestException("You can only be an admin of one free organization."); + } + } + } + + // Enforce Single Organization Policy of organization user is trying to join + var allOrgUsers = await _organizationUserRepository.GetManyByUserAsync(user.Id); + var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId); + var invitedSingleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, + PolicyType.SingleOrg, OrganizationUserStatusType.Invited); + + if (hasOtherOrgs && invitedSingleOrgPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) + { + throw new BadRequestException("You may not join this organization until you leave or remove all other organizations."); + } + + // Enforce Single Organization Policy of other organizations user is a member of + var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(user.Id, + PolicyType.SingleOrg); + if (anySingleOrgPolicies) + { + throw new BadRequestException("You cannot join this organization because you are a member of another organization which forbids it"); + } + + // Enforce Two Factor Authentication Policy of organization user is trying to join + if (!await userService.TwoFactorIsEnabledAsync(user)) + { + var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, + PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited); + if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) + { + throw new BadRequestException("You cannot join this organization until you enable two-step login on your user account."); + } + } + + orgUser.Status = OrganizationUserStatusType.Accepted; + orgUser.UserId = user.Id; + orgUser.Email = null; + + await _organizationUserRepository.ReplaceAsync(orgUser); + + var admins = await _organizationUserRepository.GetManyByMinimumRoleAsync(orgUser.OrganizationId, OrganizationUserType.Admin); + var adminEmails = admins.Select(a => a.Email).Distinct().ToList(); + + if (adminEmails.Count > 0) + { + var organization = await _organizationRepository.GetByIdAsync(orgUser.OrganizationId); + await _mailService.SendOrganizationAcceptedEmailAsync(organization, user.Email, adminEmails); + } + + return orgUser; + } + +} diff --git a/src/Core/OrganizationFeatures/OrganizationUsers/Interfaces/IAcceptOrgUserCommand.cs b/src/Core/OrganizationFeatures/OrganizationUsers/Interfaces/IAcceptOrgUserCommand.cs new file mode 100644 index 000000000..14cabda52 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationUsers/Interfaces/IAcceptOrgUserCommand.cs @@ -0,0 +1,18 @@ +using Bit.Core.Entities; +using Bit.Core.Services; + +namespace Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; + +public interface IAcceptOrgUserCommand +{ + /// + /// Moves an OrganizationUser into the Accepted status and marks their email as verified. + /// This method is used where the user has clicked the invitation link sent by email. + /// + /// The token embedded in the email invitation link + /// The accepted OrganizationUser. + Task AcceptOrgUserByEmailTokenAsync(Guid organizationUserId, User user, string emailToken, IUserService userService); + Task AcceptOrgUserByOrgSsoIdAsync(string orgIdentifier, User user, IUserService userService); + Task AcceptOrgUserByOrgIdAsync(Guid organizationId, User user, IUserService userService); + Task AcceptOrgUserAsync(OrganizationUser orgUser, User user, IUserService userService); +} diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index 044c4f147..6da6d8fd7 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -40,15 +40,6 @@ public interface IOrganizationService OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, IEnumerable groups); Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId); Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false); - /// - /// Moves an OrganizationUser into the Accepted status and marks their email as verified. - /// This method is used where the user has clicked the invitation link sent by email. - /// - /// The token embedded in the email invitation link - /// The accepted OrganizationUser. - Task AcceptUserAsync(Guid organizationUserId, User user, string token, IUserService userService); - Task AcceptUserAsync(string orgIdentifier, User user, IUserService userService); - Task AcceptUserAsync(Guid organizationId, User user, IUserService userService); Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, IUserService userService); Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index e27668946..3340ec6a7 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -37,7 +37,6 @@ public interface IUserService Task ChangeEmailAsync(User user, string masterPassword, string newEmail, string newMasterPassword, string token, string key); Task ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint, string key); - Task SetPasswordAsync(User user, string newMasterPassword, string key, string orgIdentifier = null); Task SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier); Task ConvertToKeyConnectorAsync(User user); Task AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key); @@ -78,6 +77,9 @@ public interface IUserService Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user); Task TwoFactorProviderIsEnabledAsync(TwoFactorProviderType provider, ITwoFactorProvidersUser user); Task GenerateSignInTokenAsync(User user, string purpose); + + Task UpdatePasswordHash(User user, string newPassword, + bool validatePassword = true, bool refreshStamp = true); Task RotateApiKeyAsync(User user); string GetUserName(ClaimsPrincipal principal); Task SendOTPAsync(User user); diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index d3d341fd9..73eb98cd0 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; 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.Context; using Bit.Core.Entities; @@ -18,11 +19,11 @@ using Bit.Core.Models.Data.Organizations.Policies; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Settings; +using Bit.Core.Tokens; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; using Bit.Core.Utilities; -using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Logging; using Stripe; @@ -35,7 +36,6 @@ public class OrganizationService : IOrganizationService private readonly ICollectionRepository _collectionRepository; private readonly IUserRepository _userRepository; private readonly IGroupRepository _groupRepository; - private readonly IDataProtector _dataProtector; private readonly IMailService _mailService; private readonly IPushNotificationService _pushNotificationService; private readonly IPushRegistrationService _pushRegistrationService; @@ -57,6 +57,8 @@ public class OrganizationService : IOrganizationService private readonly IProviderUserRepository _providerUserRepository; private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; + private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory; + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IFeatureService _featureService; public OrganizationService( @@ -65,7 +67,6 @@ public class OrganizationService : IOrganizationService ICollectionRepository collectionRepository, IUserRepository userRepository, IGroupRepository groupRepository, - IDataProtectionProvider dataProtectionProvider, IMailService mailService, IPushNotificationService pushNotificationService, IPushRegistrationService pushRegistrationService, @@ -86,6 +87,8 @@ public class OrganizationService : IOrganizationService IProviderOrganizationRepository providerOrganizationRepository, IProviderUserRepository providerUserRepository, ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery, + IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IFeatureService featureService) { @@ -94,7 +97,6 @@ public class OrganizationService : IOrganizationService _collectionRepository = collectionRepository; _userRepository = userRepository; _groupRepository = groupRepository; - _dataProtector = dataProtectionProvider.CreateProtector("OrganizationServiceDataProtector"); _mailService = mailService; _pushNotificationService = pushNotificationService; _pushRegistrationService = pushRegistrationService; @@ -116,6 +118,8 @@ public class OrganizationService : IOrganizationService _providerUserRepository = providerUserRepository; _countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; + _orgUserInviteTokenableFactory = orgUserInviteTokenableFactory; + _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _featureService = featureService; } @@ -1060,176 +1064,33 @@ public class OrganizationService : IOrganizationService private async Task SendInvitesAsync(IEnumerable orgUsers, Organization organization) { - string MakeToken(OrganizationUser orgUser) => - _dataProtector.Protect($"OrganizationUserInvite {orgUser.Id} {orgUser.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + (OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser) + { + var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser); + var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable); + return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate)); + } - await _mailService.BulkSendOrganizationInviteEmailAsync(organization.Name, - orgUsers.Select(o => (o, new ExpiringToken(MakeToken(o), DateTime.UtcNow.AddDays(5)))), organization.PlanType == PlanType.Free); + var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair); + + await _mailService.BulkSendOrganizationInviteEmailAsync( + organization.Name, + orgUsersWithExpTokens, + organization.PlanType == PlanType.Free + ); } private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization) { - var now = DateTime.UtcNow; - var nowMillis = CoreHelpers.ToEpocMilliseconds(now); - var token = _dataProtector.Protect( - $"OrganizationUserInvite {orgUser.Id} {orgUser.Email} {nowMillis}"); - await _mailService.SendOrganizationInviteEmailAsync(organization.Name, orgUser, new ExpiringToken(token, now.AddDays(5)), organization.PlanType == PlanType.Free, initOrganization); - } - - public async Task AcceptUserAsync(Guid organizationUserId, User user, string token, - IUserService userService) - { - var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); - if (orgUser == null) - { - throw new BadRequestException("User invalid."); - } - - if (!CoreHelpers.UserInviteTokenIsValid(_dataProtector, token, user.Email, orgUser.Id, _globalSettings)) - { - throw new BadRequestException("Invalid token."); - } - - var existingOrgUserCount = await _organizationUserRepository.GetCountByOrganizationAsync( - orgUser.OrganizationId, user.Email, true); - if (existingOrgUserCount > 0) - { - if (orgUser.Status == OrganizationUserStatusType.Accepted) - { - throw new BadRequestException("Invitation already accepted. You will receive an email when your organization membership is confirmed."); - } - throw new BadRequestException("You are already part of this organization."); - } - - if (string.IsNullOrWhiteSpace(orgUser.Email) || - !orgUser.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase)) - { - throw new BadRequestException("User email does not match invite."); - } - - var organizationUser = await AcceptUserAsync(orgUser, user, userService); - - if (user.EmailVerified == false) - { - user.EmailVerified = true; - await _userRepository.ReplaceAsync(user); - } - - return organizationUser; - } - - public async Task AcceptUserAsync(string orgIdentifier, User user, IUserService userService) - { - var org = await _organizationRepository.GetByIdentifierAsync(orgIdentifier); - if (org == null) - { - throw new BadRequestException("Organization invalid."); - } - - var usersOrgs = await _organizationUserRepository.GetManyByUserAsync(user.Id); - var orgUser = usersOrgs.FirstOrDefault(u => u.OrganizationId == org.Id); - if (orgUser == null) - { - throw new BadRequestException("User not found within organization."); - } - - return await AcceptUserAsync(orgUser, user, userService); - } - - public async Task AcceptUserAsync(Guid organizationId, User user, IUserService userService) - { - var org = await _organizationRepository.GetByIdAsync(organizationId); - if (org == null) - { - throw new BadRequestException("Organization invalid."); - } - - var usersOrgs = await _organizationUserRepository.GetManyByUserAsync(user.Id); - var orgUser = usersOrgs.FirstOrDefault(u => u.OrganizationId == org.Id); - if (orgUser == null) - { - throw new BadRequestException("User not found within organization."); - } - - return await AcceptUserAsync(orgUser, user, userService); - } - - private async Task AcceptUserAsync(OrganizationUser orgUser, User user, - IUserService userService) - { - if (orgUser.Status == OrganizationUserStatusType.Revoked) - { - throw new BadRequestException("Your organization access has been revoked."); - } - - if (orgUser.Status != OrganizationUserStatusType.Invited) - { - throw new BadRequestException("Already accepted."); - } - - if (orgUser.Type == OrganizationUserType.Owner || orgUser.Type == OrganizationUserType.Admin) - { - var org = await GetOrgById(orgUser.OrganizationId); - if (org.PlanType == PlanType.Free) - { - var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync( - user.Id); - if (adminCount > 0) - { - throw new BadRequestException("You can only be an admin of one free organization."); - } - } - } - - // Enforce Single Organization Policy of organization user is trying to join - var allOrgUsers = await _organizationUserRepository.GetManyByUserAsync(user.Id); - var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId); - var invitedSingleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, - PolicyType.SingleOrg, OrganizationUserStatusType.Invited); - - if (hasOtherOrgs && invitedSingleOrgPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) - { - throw new BadRequestException("You may not join this organization until you leave or remove " + - "all other organizations."); - } - - // Enforce Single Organization Policy of other organizations user is a member of - var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(user.Id, - PolicyType.SingleOrg); - if (anySingleOrgPolicies) - { - throw new BadRequestException("You cannot join this organization because you are a member of " + - "another organization which forbids it"); - } - - // Enforce Two Factor Authentication Policy of organization user is trying to join - if (!await userService.TwoFactorIsEnabledAsync(user)) - { - var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, - PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited); - if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) - { - throw new BadRequestException("You cannot join this organization until you enable " + - "two-step login on your user account."); - } - } - - orgUser.Status = OrganizationUserStatusType.Accepted; - orgUser.UserId = user.Id; - orgUser.Email = null; - - await _organizationUserRepository.ReplaceAsync(orgUser); - - var admins = await _organizationUserRepository.GetManyByMinimumRoleAsync(orgUser.OrganizationId, OrganizationUserType.Admin); - var adminEmails = admins.Select(a => a.Email).Distinct().ToList(); - - if (adminEmails.Count > 0) - { - var organization = await _organizationRepository.GetByIdAsync(orgUser.OrganizationId); - await _mailService.SendOrganizationAcceptedEmailAsync(organization, user.Email, adminEmails); - } - - return orgUser; + var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser); + var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable); + await _mailService.SendOrganizationInviteEmailAsync( + organization.Name, + orgUser, + new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate), + organization.PlanType == PlanType.Free, + initOrganization + ); } public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 3f29d14af..abe01d2e9 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -11,6 +11,7 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Tokens; @@ -57,11 +58,12 @@ public class UserService : UserManager, IUserService, IDisposable private readonly IFido2 _fido2; private readonly ICurrentContext _currentContext; private readonly IGlobalSettings _globalSettings; - private readonly IOrganizationService _organizationService; + private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; private readonly IProviderUserRepository _providerUserRepository; private readonly IStripeSyncService _stripeSyncService; private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository; private readonly IDataProtectorTokenFactory _webAuthnLoginTokenizer; + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; public UserService( IUserRepository userRepository, @@ -90,9 +92,10 @@ public class UserService : UserManager, IUserService, IDisposable IFido2 fido2, ICurrentContext currentContext, IGlobalSettings globalSettings, - IOrganizationService organizationService, + IAcceptOrgUserCommand acceptOrgUserCommand, IProviderUserRepository providerUserRepository, IStripeSyncService stripeSyncService, + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IWebAuthnCredentialRepository webAuthnRepository, IDataProtectorTokenFactory webAuthnLoginTokenizer) : base( @@ -128,9 +131,10 @@ public class UserService : UserManager, IUserService, IDisposable _fido2 = fido2; _currentContext = currentContext; _globalSettings = globalSettings; - _organizationService = organizationService; + _acceptOrgUserCommand = acceptOrgUserCommand; _providerUserRepository = providerUserRepository; _stripeSyncService = stripeSyncService; + _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _webAuthnCredentialRepository = webAuthnRepository; _webAuthnLoginTokenizer = webAuthnLoginTokenizer; } @@ -298,8 +302,13 @@ public class UserService : UserManager, IUserService, IDisposable var tokenValid = false; if (_globalSettings.DisableUserRegistration && !string.IsNullOrWhiteSpace(token) && orgUserId.HasValue) { - tokenValid = CoreHelpers.UserInviteTokenIsValid(_organizationServiceDataProtector, token, - user.Email, orgUserId.Value, _globalSettings); + // TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete + var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( + _orgUserInviteTokenDataFactory, token, orgUserId.Value, user.Email); + + tokenValid = newTokenValid || + CoreHelpers.UserInviteTokenIsValid(_organizationServiceDataProtector, token, + user.Email, orgUserId.Value, _globalSettings); } if (_globalSettings.DisableUserRegistration && !tokenValid) @@ -730,11 +739,6 @@ public class UserService : UserManager, IUserService, IDisposable return IdentityResult.Success; } - public override Task ChangePasswordAsync(User user, string masterPassword, string newMasterPassword) - { - throw new NotImplementedException(); - } - public async Task ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint, string key) { @@ -768,40 +772,6 @@ public class UserService : UserManager, IUserService, IDisposable return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); } - public async Task SetPasswordAsync(User user, string masterPassword, string key, - string orgIdentifier = null) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - if (!string.IsNullOrWhiteSpace(user.MasterPassword)) - { - Logger.LogWarning("Change password failed for user {userId} - already has password.", user.Id); - return IdentityResult.Failed(_identityErrorDescriber.UserAlreadyHasPassword()); - } - - var result = await UpdatePasswordHash(user, masterPassword, true, false); - if (!result.Succeeded) - { - return result; - } - - user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow; - user.Key = key; - - await _userRepository.ReplaceAsync(user); - await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); - - if (!string.IsNullOrWhiteSpace(orgIdentifier)) - { - await _organizationService.AcceptUserAsync(orgIdentifier, user, this); - } - - return IdentityResult.Success; - } - public async Task SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier) { var identityResult = CheckCanUseKeyConnector(user); @@ -817,7 +787,7 @@ public class UserService : UserManager, IUserService, IDisposable await _userRepository.ReplaceAsync(user); await _eventService.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector); - await _organizationService.AcceptUserAsync(orgIdentifier, user, this); + await _acceptOrgUserCommand.AcceptOrgUserByOrgSsoIdAsync(orgIdentifier, user, this); return IdentityResult.Success; } @@ -1482,7 +1452,7 @@ public class UserService : UserManager, IUserService, IDisposable return token; } - private async Task UpdatePasswordHash(User user, string newPassword, + public async Task UpdatePasswordHash(User user, string newPassword, bool validatePassword = true, bool refreshStamp = true) { if (validatePassword) diff --git a/src/Core/Tokens/ExpiringTokenable.cs b/src/Core/Tokens/ExpiringTokenable.cs index 089405e53..5e90a2406 100644 --- a/src/Core/Tokens/ExpiringTokenable.cs +++ b/src/Core/Tokens/ExpiringTokenable.cs @@ -7,7 +7,16 @@ public abstract class ExpiringTokenable : Tokenable { [JsonConverter(typeof(EpochDateTimeJsonConverter))] public DateTime ExpirationDate { get; set; } + + /// + /// Checks if the token is still within its valid duration and if its data is valid. + /// For data validation, this property relies on the method. + /// public override bool Valid => ExpirationDate > DateTime.UtcNow && TokenIsValid(); + /// + /// Validates that the token data properties are correct. + /// For expiration checks, refer to the property. + /// protected abstract bool TokenIsValid(); } diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index b125fba49..b44283f6e 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -495,6 +495,7 @@ public static class CoreHelpers return string.Concat("Custom_", type.ToString()); } + // TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete public static bool UserInviteTokenIsValid(IDataProtector protector, string token, string userEmail, Guid orgUserId, IGlobalSettings globalSettings) { diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index e49bf9192..c12c849e3 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -13,6 +13,7 @@ using Bit.Core.Auth.LoginFeatures; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; using Bit.Core.Auth.Services.Implementations; +using Bit.Core.Auth.UserFeatures; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.HostedServices; @@ -127,7 +128,7 @@ public static class ServiceCollectionExtensions public static void AddBaseServices(this IServiceCollection services, IGlobalSettings globalSettings) { services.AddScoped(); - services.AddScoped(); + services.AddUserServices(globalSettings); services.AddOrganizationServices(globalSettings); services.AddScoped(); services.AddScoped(); @@ -152,6 +153,7 @@ public static class ServiceCollectionExtensions serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>()) ); + services.AddSingleton>(serviceProvider => new DataProtectorTokenFactory( HCaptchaTokenable.ClearTextPrefix, @@ -159,6 +161,7 @@ public static class ServiceCollectionExtensions serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>()) ); + services.AddSingleton>(serviceProvider => new DataProtectorTokenFactory( SsoTokenable.ClearTextPrefix, @@ -183,6 +186,14 @@ public static class ServiceCollectionExtensions SsoEmail2faSessionTokenable.DataProtectorPurpose, serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>())); + + services.AddSingleton(); + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + OrgUserInviteTokenable.ClearTextPrefix, + OrgUserInviteTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>())); } public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings) diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index 4b5c633cb..b62027919 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -2,6 +2,7 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations.Policies; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; @@ -27,7 +28,7 @@ public class OrganizationUsersControllerTests await sutProvider.Sut.PutResetPasswordEnrollment(orgId, userId, model); - await sutProvider.GetDependency().Received(1).AcceptUserAsync(orgId, user, sutProvider.GetDependency()); + await sutProvider.GetDependency().Received(1).AcceptOrgUserByOrgIdAsync(orgId, user, sutProvider.GetDependency()); } [Theory] @@ -41,7 +42,7 @@ public class OrganizationUsersControllerTests await sutProvider.Sut.PutResetPasswordEnrollment(orgId, userId, model); - await sutProvider.GetDependency().Received(0).AcceptUserAsync(orgId, user, sutProvider.GetDependency()); + await sutProvider.GetDependency().Received(0).AcceptOrgUserByOrgIdAsync(orgId, user, sutProvider.GetDependency()); } [Theory] @@ -63,8 +64,8 @@ public class OrganizationUsersControllerTests await sutProvider.Sut.Accept(orgId, orgUserId, model); - await sutProvider.GetDependency().Received(1) - .AcceptUserAsync(orgUserId, user, model.Token, sutProvider.GetDependency()); + await sutProvider.GetDependency().Received(1) + .AcceptOrgUserByEmailTokenAsync(orgUserId, user, model.Token, sutProvider.GetDependency()); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .UpdateUserResetPasswordEnrollmentAsync(default, default, default, default); } @@ -85,8 +86,8 @@ public class OrganizationUsersControllerTests await sutProvider.Sut.Accept(orgId, orgUserId, model); - await sutProvider.GetDependency().Received(1) - .AcceptUserAsync(orgUserId, user, model.Token, sutProvider.GetDependency()); + await sutProvider.GetDependency().Received(1) + .AcceptOrgUserByEmailTokenAsync(orgUserId, user, model.Token, sutProvider.GetDependency()); await sutProvider.GetDependency().Received(1) .UpdateUserResetPasswordEnrollmentAsync(orgId, user.Id, model.ResetPasswordKey, user.Id); } diff --git a/test/Api.Test/Controllers/AccountsControllerTests.cs b/test/Api.Test/Controllers/AccountsControllerTests.cs index 0bd368790..491f5a9f4 100644 --- a/test/Api.Test/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Controllers/AccountsControllerTests.cs @@ -4,6 +4,7 @@ using Bit.Api.Controllers; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -14,6 +15,7 @@ using Bit.Core.Settings; using Bit.Core.Tools.Repositories; using Bit.Core.Tools.Services; using Bit.Core.Vault.Repositories; +using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Identity; using NSubstitute; using Xunit; @@ -37,6 +39,7 @@ public class AccountsControllerTests : IDisposable private readonly IProviderUserRepository _providerUserRepository; private readonly ICaptchaValidationService _captchaValidationService; private readonly IPolicyService _policyService; + private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; public AccountsControllerTests() { @@ -53,6 +56,8 @@ public class AccountsControllerTests : IDisposable _sendService = Substitute.For(); _captchaValidationService = Substitute.For(); _policyService = Substitute.For(); + _setInitialMasterPasswordCommand = Substitute.For(); + _sut = new AccountsController( _globalSettings, _cipherRepository, @@ -66,7 +71,8 @@ public class AccountsControllerTests : IDisposable _sendRepository, _sendService, _captchaValidationService, - _policyService + _policyService, + _setInitialMasterPasswordCommand ); } @@ -381,6 +387,124 @@ public class AccountsControllerTests : IDisposable ); } + + [Theory] + [BitAutoData(true, false)] // User has PublicKey and PrivateKey, and Keys in request are NOT null + [BitAutoData(true, true)] // User has PublicKey and PrivateKey, and Keys in request are null + [BitAutoData(false, false)] // User has neither PublicKey nor PrivateKey, and Keys in request are NOT null + [BitAutoData(false, true)] // User has neither PublicKey nor PrivateKey, and Keys in request are null + public async Task PostSetPasswordAsync_WhenUserExistsAndSettingPasswordSucceeds_ShouldHandleKeysCorrectlyAndReturn( + bool hasExistingKeys, + bool shouldSetKeysToNull, + User user, + SetPasswordRequestModel setPasswordRequestModel) + { + // Arrange + const string existingPublicKey = "existingPublicKey"; + const string existingEncryptedPrivateKey = "existingEncryptedPrivateKey"; + + const string newPublicKey = "newPublicKey"; + const string newEncryptedPrivateKey = "newEncryptedPrivateKey"; + + if (hasExistingKeys) + { + user.PublicKey = existingPublicKey; + user.PrivateKey = existingEncryptedPrivateKey; + } + else + { + user.PublicKey = null; + user.PrivateKey = null; + } + + if (shouldSetKeysToNull) + { + setPasswordRequestModel.Keys = null; + } + else + { + setPasswordRequestModel.Keys = new KeysRequestModel() + { + PublicKey = newPublicKey, + EncryptedPrivateKey = newEncryptedPrivateKey + }; + } + + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync( + user, + setPasswordRequestModel.MasterPasswordHash, + setPasswordRequestModel.Key, + setPasswordRequestModel.OrgIdentifier) + .Returns(Task.FromResult(IdentityResult.Success)); + + // Act + await _sut.PostSetPasswordAsync(setPasswordRequestModel); + + // Assert + await _setInitialMasterPasswordCommand.Received(1) + .SetInitialMasterPasswordAsync( + Arg.Is(u => u == user), + Arg.Is(s => s == setPasswordRequestModel.MasterPasswordHash), + Arg.Is(s => s == setPasswordRequestModel.Key), + Arg.Is(s => s == setPasswordRequestModel.OrgIdentifier)); + + // Additional Assertions for User object modifications + Assert.Equal(setPasswordRequestModel.MasterPasswordHint, user.MasterPasswordHint); + Assert.Equal(setPasswordRequestModel.Kdf, user.Kdf); + Assert.Equal(setPasswordRequestModel.KdfIterations, user.KdfIterations); + Assert.Equal(setPasswordRequestModel.KdfMemory, user.KdfMemory); + Assert.Equal(setPasswordRequestModel.KdfParallelism, user.KdfParallelism); + Assert.Equal(setPasswordRequestModel.Key, user.Key); + + if (hasExistingKeys) + { + // User Keys should not be modified + Assert.Equal(existingPublicKey, user.PublicKey); + Assert.Equal(existingEncryptedPrivateKey, user.PrivateKey); + } + else if (!shouldSetKeysToNull) + { + // User had no keys so they should be set to the request model's keys + Assert.Equal(setPasswordRequestModel.Keys.PublicKey, user.PublicKey); + Assert.Equal(setPasswordRequestModel.Keys.EncryptedPrivateKey, user.PrivateKey); + } + else + { + // User had no keys and the request model's keys were null, so they should be set to null + Assert.Null(user.PublicKey); + Assert.Null(user.PrivateKey); + } + } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_WhenUserDoesNotExist_ShouldThrowUnauthorizedAccessException( + SetPasswordRequestModel setPasswordRequestModel) + { + // Arrange + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult((User)null)); + + // Act & Assert + await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setPasswordRequestModel)); + } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_WhenSettingPasswordFails_ShouldThrowBadRequestException( + User user, + SetPasswordRequestModel model) + { + // Arrange + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(IdentityResult.Failed(new IdentityError { Description = "Some Error" }))); + + // Act & Assert + await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(model)); + } + + // Below are helper functions that currently belong to this // test class, but ultimately may need to be split out into // something greater in order to share common test steps with diff --git a/test/Api.Test/Controllers/PoliciesControllerTests.cs b/test/Api.Test/Controllers/PoliciesControllerTests.cs new file mode 100644 index 000000000..0d2f004b1 --- /dev/null +++ b/test/Api.Test/Controllers/PoliciesControllerTests.cs @@ -0,0 +1,133 @@ +using System.Security.Claims; +using System.Text.Json; +using Bit.Api.AdminConsole.Controllers; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.Policies; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Controllers; + + +// Note: test names follow MethodName_StateUnderTest_ExpectedBehavior pattern. +[ControllerCustomize(typeof(PoliciesController))] +[SutProviderCustomize] +public class PoliciesControllerTests +{ + + [Theory] + [BitAutoData] + public async Task GetMasterPasswordPolicy_WhenCalled_ReturnsMasterPasswordPolicy( + SutProvider sutProvider, Guid orgId, Guid userId, OrganizationUser orgUser, + Policy policy, MasterPasswordPolicyData mpPolicyData) + { + // Arrange + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns((Guid?)userId); + + sutProvider.GetDependency() + .GetByOrganizationAsync(orgId, userId) + .Returns(orgUser); + + + policy.Type = PolicyType.MasterPassword; + policy.Enabled = true; + // data should be a JSON serialized version of the mpPolicyData object + policy.Data = JsonSerializer.Serialize(mpPolicyData); + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(orgId, PolicyType.MasterPassword) + .Returns(policy); + + // Act + var result = await sutProvider.Sut.GetMasterPasswordPolicy(orgId); + + // Assert + + Assert.NotNull(result); + Assert.Equal(policy.Id, result.Id); + Assert.Equal(policy.Type, result.Type); + Assert.Equal(policy.Enabled, result.Enabled); + + // Assert that the data is deserialized correctly into a Dictionary + // for all MasterPasswordPolicyData properties + Assert.Equal(mpPolicyData.MinComplexity, ((JsonElement)result.Data["MinComplexity"]).GetInt32()); + Assert.Equal(mpPolicyData.MinLength, ((JsonElement)result.Data["MinLength"]).GetInt32()); + Assert.Equal(mpPolicyData.RequireLower, ((JsonElement)result.Data["RequireLower"]).GetBoolean()); + Assert.Equal(mpPolicyData.RequireUpper, ((JsonElement)result.Data["RequireUpper"]).GetBoolean()); + Assert.Equal(mpPolicyData.RequireNumbers, ((JsonElement)result.Data["RequireNumbers"]).GetBoolean()); + Assert.Equal(mpPolicyData.RequireSpecial, ((JsonElement)result.Data["RequireSpecial"]).GetBoolean()); + Assert.Equal(mpPolicyData.EnforceOnLogin, ((JsonElement)result.Data["EnforceOnLogin"]).GetBoolean()); + } + + + [Theory] + [BitAutoData] + public async Task GetMasterPasswordPolicy_OrgUserIsNull_ThrowsNotFoundException( + SutProvider sutProvider, Guid orgId, Guid userId) + { + // Arrange + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns((Guid?)userId); + + sutProvider.GetDependency() + .GetByOrganizationAsync(orgId, userId) + .Returns((OrganizationUser)null); + + // Act & Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.GetMasterPasswordPolicy(orgId)); + } + + [Theory] + [BitAutoData] + public async Task GetMasterPasswordPolicy_PolicyIsNull_ThrowsNotFoundException( + SutProvider sutProvider, Guid orgId, Guid userId, OrganizationUser orgUser) + { + // Arrange + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns((Guid?)userId); + + sutProvider.GetDependency() + .GetByOrganizationAsync(orgId, userId) + .Returns(orgUser); + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(orgId, PolicyType.MasterPassword) + .Returns((Policy)null); + + // Act & Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.GetMasterPasswordPolicy(orgId)); + } + + [Theory] + [BitAutoData] + public async Task GetMasterPasswordPolicy_PolicyNotEnabled_ThrowsNotFoundException( + SutProvider sutProvider, Guid orgId, Guid userId, OrganizationUser orgUser, Policy policy) + { + // Arrange + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns((Guid?)userId); + + sutProvider.GetDependency() + .GetByOrganizationAsync(orgId, userId) + .Returns(orgUser); + + policy.Enabled = false; // Ensuring the policy is not enabled + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(orgId, PolicyType.MasterPassword) + .Returns(policy); + + // Act & Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.GetMasterPasswordPolicy(orgId)); + } +} diff --git a/test/Common/Fakes/FakeDataProtectorTokenFactory.cs b/test/Common/Fakes/FakeDataProtectorTokenFactory.cs new file mode 100644 index 000000000..fe3af320d --- /dev/null +++ b/test/Common/Fakes/FakeDataProtectorTokenFactory.cs @@ -0,0 +1,55 @@ +using Bit.Core.Tokens; + +namespace Bit.Test.Common.Fakes; + +/// +/// Used to fake the IDataProtectorTokenFactory for testing purposes. +/// Generalized for use with all Tokenables. +/// +public class FakeDataProtectorTokenFactory : IDataProtectorTokenFactory where T : Tokenable, new() +{ + // Instead of real encryption, use a simple Dictionary to emulate protection/unprotection + private readonly Dictionary _tokenDatabase = new Dictionary(); + + public string Protect(T data) + { + // Generate a simple token representation + var token = Guid.NewGuid().ToString(); + + // Store the data against the token + _tokenDatabase[token] = data; + + return token; + } + + public T Unprotect(string token) + { + // If the token exists in the dictionary, return the corresponding data + if (_tokenDatabase.TryGetValue(token, out var data)) + { + return data; + } + + // If the token doesn't exist, throw an exception similar to a decryption failure. + throw new Exception("Failed to unprotect token."); + } + + public bool TryUnprotect(string token, out T data) + { + try + { + data = Unprotect(token); + return true; + } + catch + { + data = default; + return false; + } + } + + public bool TokenValid(string token) + { + return _tokenDatabase.ContainsKey(token); + } +} diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs new file mode 100644 index 000000000..aeeda206f --- /dev/null +++ b/test/Core.Test/Auth/Models/Business/Tokenables/OrgUserInviteTokenableTests.cs @@ -0,0 +1,264 @@ +using AutoFixture.Xunit2; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Entities; +using Bit.Core.Tokens; +using Xunit; + + +namespace Bit.Core.Test.Auth.Models.Business.Tokenables; + +// Note: test names follow MethodName_StateUnderTest_ExpectedBehavior pattern. +public class OrgUserInviteTokenableTests +{ + // Allow a small tolerance for possible execution delays or clock precision. + private readonly TimeSpan _timeTolerance = TimeSpan.FromMilliseconds(10); + + /// + /// Tests that the default constructor sets the expiration date to the expected duration. + /// + [Fact] + public void Constructor_DefaultInitialization_ExpirationSetToExpectedDuration() + { + var token = new OrgUserInviteTokenable(); + var expectedExpiration = DateTime.UtcNow + OrgUserInviteTokenable.GetTokenLifetime(); + + Assert.True(TimesAreCloseEnough(expectedExpiration, token.ExpirationDate, _timeTolerance)); + } + + /// + /// Tests that the constructor sets the properties correctly from a valid OrganizationUser object. + /// + [Theory, AutoData] + public void Constructor_ValidOrgUser_PropertiesSetFromOrgUser(OrganizationUser orgUser) + { + var token = new OrgUserInviteTokenable(orgUser); + + Assert.Equal(orgUser.Id, token.OrgUserId); + Assert.Equal(orgUser.Email, token.OrgUserEmail); + } + + /// + /// Tests that the constructor sets the properties to default values when given a null OrganizationUser object. + /// + [Fact] + public void Constructor_NullOrgUser_PropertiesSetToDefault() + { + var token = new OrgUserInviteTokenable(null); + + Assert.Equal(default, token.OrgUserId); + Assert.Equal(default, token.OrgUserEmail); + } + + /// + /// Tests that a custom expiration date is preserved after token initialization. + /// + [Fact] + public void Constructor_CustomExpirationDate_ExpirationMatchesProvidedValue() + { + var customExpiration = DateTime.UtcNow.AddHours(3); + var token = new OrgUserInviteTokenable + { + ExpirationDate = customExpiration + }; + + Assert.True(TimesAreCloseEnough(customExpiration, token.ExpirationDate, _timeTolerance)); + } + + /// + /// Tests the validity of a token initialized with a null org user. + /// + [Fact] + public void Valid_NullOrgUser_ReturnsFalse() + { + var token = new OrgUserInviteTokenable(null); + + Assert.False(token.Valid); + } + + /// + /// Tests the validity of a token with a non-matching identifier. + /// + [Fact] + public void Valid_WrongIdentifier_ReturnsFalse() + { + var token = new OrgUserInviteTokenable + { + Identifier = "IncorrectIdentifier" + }; + + Assert.False(token.Valid); + } + + /// + /// Tests the validity of the token when the OrgUserId is set to default. + /// + [Fact] + public void Valid_DefaultOrgUserId_ReturnsFalse() + { + var token = new OrgUserInviteTokenable + { + OrgUserId = default // Guid.Empty + }; + + Assert.False(token.Valid); + } + + /// + /// Tests the validity of the token when the OrgUserEmail is null or empty. + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void Valid_NullOrEmptyOrgUserEmail_ReturnsFalse(string email) + { + var token = new OrgUserInviteTokenable + { + OrgUserEmail = email + }; + + Assert.False(token.Valid); + } + + + /// + /// Tests the validity of the token when the token is expired. + /// + [Fact] + public void Valid_ExpiredToken_ReturnsFalse() + { + var expiredDate = DateTime.UtcNow.AddHours(-3); + var token = new OrgUserInviteTokenable + { + ExpirationDate = expiredDate + }; + + Assert.False(token.Valid); + } + + + /// + /// Tests the TokenIsValid method when given a null OrganizationUser object. + /// + [Fact] + public void TokenIsValid_NullOrgUser_ReturnsFalse() + { + var token = new OrgUserInviteTokenable(null); + + Assert.False(token.TokenIsValid(null)); + } + + /// + /// Tests the TokenIsValid method when the OrgUserId does not match. + /// + [Theory, AutoData] + public void TokenIsValid_WrongUserId_ReturnsFalse(OrganizationUser orgUser) + { + var token = new OrgUserInviteTokenable(orgUser) + { + OrgUserId = Guid.NewGuid() // Force a different ID + }; + + Assert.False(token.TokenIsValid(orgUser)); + } + + /// + /// Tests the TokenIsValid method when the OrgUserEmail does not match. + /// + [Theory, AutoData] + public void TokenIsValid_WrongEmail_ReturnsFalse(OrganizationUser orgUser) + { + var token = new OrgUserInviteTokenable(orgUser) + { + OrgUserEmail = "wrongemail@example.com" // Force a different email + }; + + Assert.False(token.TokenIsValid(orgUser)); + } + + /// + /// Tests the TokenIsValid method when both OrgUserId and OrgUserEmail match. + /// + [Theory, AutoData] + public void TokenIsValid_MatchingUserIdAndEmail_ReturnsTrue(OrganizationUser orgUser) + { + var token = new OrgUserInviteTokenable(orgUser); + + Assert.True(token.TokenIsValid(orgUser)); + } + + /// + /// Tests the TokenIsValid method to ensure email comparison is case-insensitive. + /// + [Theory, AutoData] + public void TokenIsValid_EmailCaseInsensitiveComparison_ReturnsTrue(OrganizationUser orgUser) + { + var token = new OrgUserInviteTokenable(orgUser); + + // Modify the orgUser's email case + orgUser.Email = orgUser.Email.ToUpperInvariant(); + + Assert.True(token.TokenIsValid(orgUser)); + } + + + /// + /// Tests the TokenIsValid method when the token is expired. + /// Should return true as TokenIsValid only validates token data -- not token expiration. + /// + [Theory, AutoData] + public void TokenIsValid_ExpiredToken_ReturnsTrue(OrganizationUser orgUser) + { + var expiredDate = DateTime.UtcNow.AddHours(-3); + var token = new OrgUserInviteTokenable(orgUser) + { + ExpirationDate = expiredDate + }; + + Assert.True(token.TokenIsValid(orgUser)); + } + + /// + /// Tests the deserialization of a token to ensure that the ExpirationDate is preserved. + /// + [Theory, AutoData] + public void FromToken_SerializedToken_PreservesExpirationDate(OrganizationUser orgUser) + { + // Arbitrary time for testing + var expectedDateTime = DateTime.UtcNow.AddHours(-3); + var token = new OrgUserInviteTokenable(orgUser) + { + ExpirationDate = expectedDateTime + }; + + var result = Tokenable.FromToken(token.ToToken()); + + Assert.True(TimesAreCloseEnough(expectedDateTime, result.ExpirationDate, _timeTolerance)); + } + + /// + /// Tests the deserialization of a token to ensure that the OrgUserId property is preserved. + /// + [Theory, AutoData] + public void FromToken_SerializedToken_PreservesOrgUserId(OrganizationUser orgUser) + { + var token = new OrgUserInviteTokenable(orgUser); + var result = Tokenable.FromToken(token.ToToken()); + Assert.Equal(orgUser.Id, result.OrgUserId); + } + + /// + /// Tests the deserialization of a token to ensure that the OrgUserEmail property is preserved. + /// + [Theory, AutoData] + public void FromToken_SerializedToken_PreservesOrgUserEmail(OrganizationUser orgUser) + { + var token = new OrgUserInviteTokenable(orgUser); + var result = Tokenable.FromToken(token.ToToken()); + Assert.Equal(orgUser.Email, result.OrgUserEmail); + } + + private bool TimesAreCloseEnough(DateTime time1, DateTime time2, TimeSpan tolerance) + { + return (time1 - time2).Duration() < tolerance; + } +} diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandTests.cs new file mode 100644 index 000000000..a35297643 --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandTests.cs @@ -0,0 +1,193 @@ +using Bit.Core.Auth.UserFeatures.UserMasterPassword; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.UserMasterPassword; + +[SutProviderCustomize] +public class SetInitialMasterPasswordCommandTests +{ + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_Success(SutProvider sutProvider, + User user, string masterPassword, string key, string orgIdentifier, + Organization org, OrganizationUser orgUser) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgIdentifier) + .Returns(org); + + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns(orgUser); + + // Act + var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + + // Assert + Assert.Equal(IdentityResult.Success, result); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_UserIsNull_ThrowsArgumentNullException(SutProvider sutProvider, string masterPassword, string key, string orgIdentifier) + { + // Act & Assert + await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(null, masterPassword, key, orgIdentifier)); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_AlreadyHasPassword_ReturnsFalse(SutProvider sutProvider, User user, string masterPassword, string key, string orgIdentifier) + { + // Arrange + user.MasterPassword = "ExistingPassword"; + + // Act + var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + + // Assert + Assert.False(result.Succeeded); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_NullOrgSsoIdentifier_ThrowsBadRequestException( + SutProvider sutProvider, User user, string masterPassword, string key) + { + // Arrange + user.MasterPassword = null; + string orgSsoIdentifier = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgSsoIdentifier)); + Assert.Equal("Organization SSO Identifier required.", exception.Message); + } + + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_InvalidOrganization_Throws(SutProvider sutProvider, User user, string masterPassword, string key, string orgIdentifier) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgIdentifier) + .ReturnsNull(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier)); + Assert.Equal("Organization invalid.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_UserNotFoundInOrganization_Throws(SutProvider sutProvider, User user, string masterPassword, string key, Organization org) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(Arg.Any()) + .Returns(org); + + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .ReturnsNull(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, org.Identifier)); + Assert.Equal("User not found within organization.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_ConfirmedOrgUser_DoesNotCallAcceptOrgUser(SutProvider sutProvider, + User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgIdentifier) + .Returns(org); + + orgUser.Status = OrganizationUserStatusType.Confirmed; + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns(orgUser); + + + // Act + var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + + // Assert + Assert.Equal(IdentityResult.Success, result); + await sutProvider.GetDependency().DidNotReceive().AcceptOrgUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_InvitedOrgUser_CallsAcceptOrgUser(SutProvider sutProvider, + User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgIdentifier) + .Returns(org); + + orgUser.Status = OrganizationUserStatusType.Invited; + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns(orgUser); + + // Act + var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + + // Assert + Assert.Equal(IdentityResult.Success, result); + await sutProvider.GetDependency().Received(1).AcceptOrgUserAsync(orgUser, user, sutProvider.GetDependency()); + } + +} diff --git a/test/Core.Test/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs new file mode 100644 index 000000000..33ad63d02 --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs @@ -0,0 +1,664 @@ +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.OrganizationFeatures.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Core.Tokens; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Fakes; +using Microsoft.AspNetCore.DataProtection; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.OrganizationFeatures.OrganizationUsers; + +// Note: test names follow MethodName_StateUnderTest_ExpectedBehavior pattern. +[SutProviderCustomize] +public class AcceptOrgUserCommandTests +{ + private readonly IUserService _userService = Substitute.For(); + private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory = Substitute.For(); + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory(); + + // Base AcceptOrgUserAsync method tests ---------------------------------------------------------------------------- + + [Theory] + [BitAutoData] + public async Task AcceptOrgUser_InvitedUserToSingleOrg_AcceptsOrgUser( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + // Act + var resultOrgUser = await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService); + + // Assert + // Verify returned org user details + AssertValidAcceptedOrgUser(resultOrgUser, orgUser, user); + + // Verify org repository called with updated orgUser + await sutProvider.GetDependency().Received(1).ReplaceAsync( + Arg.Is(ou => ou.Id == orgUser.Id && ou.Status == OrganizationUserStatusType.Accepted)); + + // Verify emails sent to admin + await sutProvider.GetDependency().Received(1).SendOrganizationAcceptedEmailAsync( + Arg.Is(o => o.Id == org.Id), + Arg.Is(e => e == user.Email), + Arg.Is>(a => a.Contains(adminUserDetails.Email)) + ); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUser_OrgUserStatusIsRevoked_ReturnsBadRequest( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Common setup + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + // Revoke user status + orgUser.Status = OrganizationUserStatusType.Revoked; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService)); + + Assert.Equal("Your organization access has been revoked.", exception.Message); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Accepted)] + [BitAutoData(OrganizationUserStatusType.Confirmed)] + public async Task AcceptOrgUser_OrgUserStatusIsNotInvited_ThrowsBadRequest( + OrganizationUserStatusType orgUserStatus, + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + // Set status to something other than invited + orgUser.Status = orgUserStatus; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService)); + + Assert.Equal("Already accepted.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUser_UserJoiningOrgWithSingleOrgPolicyWhileInAnotherOrg_ThrowsBadRequest( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + // Make user part of another org + var otherOrgUser = new OrganizationUser { UserId = user.Id, OrganizationId = Guid.NewGuid() }; // random org ID + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns(Task.FromResult>(new List { otherOrgUser })); + + // Make organization they are trying to join have the single org policy + var singleOrgPolicy = new OrganizationUserPolicyDetails { OrganizationId = orgUser.OrganizationId }; + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg, OrganizationUserStatusType.Invited) + .Returns(Task.FromResult>( + new List { singleOrgPolicy })); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService)); + + Assert.Equal("You may not join this organization until you leave or remove all other organizations.", + exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserAsync_UserInOrgWithSingleOrgPolicyAlready_ThrowsBadRequest( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + // Mock that user is part of an org that has the single org policy + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService)); + + Assert.Equal( + "You cannot join this organization because you are a member of another organization which forbids it", + exception.Message); + } + + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserAsync_UserWithout2FAJoining2FARequiredOrg_ThrowsBadRequest( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + // User doesn't have 2FA enabled + _userService.TwoFactorIsEnabledAsync(user).Returns(false); + + // Organization they are trying to join requires 2FA + var twoFactorPolicy = new OrganizationUserPolicyDetails { OrganizationId = orgUser.OrganizationId }; + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication, + OrganizationUserStatusType.Invited) + .Returns(Task.FromResult>( + new List { twoFactorPolicy })); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService)); + + Assert.Equal("You cannot join this organization until you enable two-step login on your user account.", + exception.Message); + } + + + // AcceptOrgUserByOrgIdAsync tests -------------------------------------------------------------------------------- + + [Theory] + [EphemeralDataProtectionAutoData] + public async Task AcceptOrgUserByToken_OldToken_AcceptsUserAndVerifiesEmail( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + SetupCommonAcceptOrgUserByTokenMocks(sutProvider, user, orgUser); + + var oldToken = CreateOldToken(sutProvider, orgUser); + + // Act + var resultOrgUser = await sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUser.Id, user, oldToken, _userService); + + // Assert + AssertValidAcceptedOrgUser(resultOrgUser, orgUser, user); + + // Verify user email verified logic + Assert.True(user.EmailVerified); + await sutProvider.GetDependency().Received(1).ReplaceAsync( + Arg.Is(u => u.Id == user.Id && u.Email == user.Email && user.EmailVerified == true)); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserByToken_NewToken_AcceptsUserAndVerifiesEmail( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order + // to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + SetupCommonAcceptOrgUserByTokenMocks(sutProvider, user, orgUser); + + // Must come after common mocks as they mutate the org user. + // Mock tokenable factory to return a token that expires in 5 days + _orgUserInviteTokenableFactory.CreateToken(orgUser).Returns(new OrgUserInviteTokenable(orgUser) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + }); + + var newToken = CreateNewToken(orgUser); + + // Act + var resultOrgUser = await sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUser.Id, user, newToken, _userService); + + // Assert + AssertValidAcceptedOrgUser(resultOrgUser, orgUser, user); + + // Verify user email verified logic + Assert.True(user.EmailVerified); + await sutProvider.GetDependency().Received(1).ReplaceAsync( + Arg.Is(u => u.Id == user.Id && u.Email == user.Email && user.EmailVerified == true)); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserByToken_NullOrgUser_ThrowsBadRequest( + SutProvider sutProvider, + User user, Guid orgUserId) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(orgUserId).Returns((OrganizationUser)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUserId, user, "token", _userService)); + + Assert.Equal("User invalid.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserByToken_GenericInvalidToken_ThrowsBadRequest( + SutProvider sutProvider, + User user, OrganizationUser orgUser) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(orgUser.Id) + .Returns(Task.FromResult(orgUser)); + + var invalidToken = "invalidToken"; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUser.Id, user, invalidToken, _userService)); + + Assert.Equal("Invalid token.", exception.Message); + } + + [Theory] + [EphemeralDataProtectionAutoData] + public async Task AcceptOrgUserByToken_ExpiredOldToken_ThrowsBadRequest( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + SetupCommonAcceptOrgUserByTokenMocks(sutProvider, user, orgUser); + + // As the old token simply set a timestamp which was later compared against the + // OrganizationInviteExpirationHours global setting to determine if it was expired or not, + // we can simply set the expiration to 24 hours ago to simulate an expired token. + sutProvider.GetDependency().OrganizationInviteExpirationHours.Returns(-24); + + var oldToken = CreateOldToken(sutProvider, orgUser); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUser.Id, user, oldToken, _userService)); + + Assert.Equal("Invalid token.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserByToken_ExpiredNewToken_ThrowsBadRequest( + SutProvider sutProvider, + User user, OrganizationUser orgUser) + { + // Arrange + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order + // to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.Id) + .Returns(Task.FromResult(orgUser)); + + // Must come after common mocks as they mutate the org user. + // Mock tokenable factory to return a token that expired yesterday + _orgUserInviteTokenableFactory.CreateToken(orgUser).Returns(new OrgUserInviteTokenable(orgUser) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(-1)) + }); + + var newToken = CreateNewToken(orgUser); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUser.Id, user, newToken, _userService)); + + Assert.Equal("Invalid token.", exception.Message); + + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Accepted, + "Invitation already accepted. You will receive an email when your organization membership is confirmed.")] + [BitAutoData(OrganizationUserStatusType.Confirmed, + "You are already part of this organization.")] + public async Task AcceptOrgUserByToken_UserAlreadyInOrg_ThrowsBadRequest( + OrganizationUserStatusType statusType, + string expectedErrorMessage, + SutProvider sutProvider, + User user, OrganizationUser orgUser) + { + // Arrange + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order + // to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.Id) + .Returns(Task.FromResult(orgUser)); + + // Indicate that a user with the given email already exists in the organization + sutProvider.GetDependency() + .GetCountByOrganizationAsync(orgUser.OrganizationId, user.Email, true) + .Returns(1); + + orgUser.Status = statusType; + + // Must come after common mocks as they mutate the org user. + // Mock tokenable factory to return valid, new token that expires in 5 days + _orgUserInviteTokenableFactory.CreateToken(orgUser).Returns(new OrgUserInviteTokenable(orgUser) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + }); + + var newToken = CreateNewToken(orgUser); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUser.Id, user, newToken, _userService)); + + Assert.Equal(expectedErrorMessage, exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserByToken_EmailMismatch_ThrowsBadRequest( + SutProvider sutProvider, + User user, OrganizationUser orgUser) + { + // Arrange + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order + // to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + SetupCommonAcceptOrgUserByTokenMocks(sutProvider, user, orgUser); + + // Modify the orgUser's email to be different from the user's email to simulate the mismatch + orgUser.Email = "mismatchedEmail@example.com"; + + // Must come after common mocks as they mutate the org user. + // Mock tokenable factory to return a token that expires in 5 days + _orgUserInviteTokenableFactory.CreateToken(orgUser).Returns(new OrgUserInviteTokenable(orgUser) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + }); + + var newToken = CreateNewToken(orgUser); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUser.Id, user, newToken, _userService)); + + Assert.Equal("User email does not match invite.", exception.Message); + } + + + // AcceptOrgUserByOrgSsoIdAsync ----------------------------------------------------------------------------------- + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserByOrgSsoIdAsync_ValidData_AcceptsOrgUser( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + sutProvider.GetDependency() + .GetByIdentifierAsync(org.Identifier) + .Returns(org); + + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns(orgUser); + + // Act + var resultOrgUser = await sutProvider.Sut.AcceptOrgUserByOrgSsoIdAsync(org.Identifier, user, _userService); + + // Assert + AssertValidAcceptedOrgUser(resultOrgUser, orgUser, user); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserByOrgSsoIdAsync_InvalidOrg_ThrowsBadRequest(SutProvider sutProvider, + string orgSsoIdentifier, User user) + { + // Arrange + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgSsoIdentifier) + .Returns((Organization)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptOrgUserByOrgSsoIdAsync(orgSsoIdentifier, user, _userService)); + + Assert.Equal("Organization invalid.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserByOrgSsoIdAsync_UserNotInOrg_ThrowsBadRequest(SutProvider sutProvider, + Organization org, User user) + { + // Arrange + sutProvider.GetDependency() + .GetByIdentifierAsync(org.Identifier) + .Returns(org); + + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns((OrganizationUser)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptOrgUserByOrgSsoIdAsync(org.Identifier, user, _userService)); + + Assert.Equal("User not found within organization.", exception.Message); + } + + // AcceptOrgUserByOrgIdAsync --------------------------------------------------------------------------------------- + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserByOrgId_ValidData_AcceptsOrgUser( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + sutProvider.GetDependency() + .GetByIdAsync(org.Id) + .Returns(org); + + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns(orgUser); + + // Act + var resultOrgUser = await sutProvider.Sut.AcceptOrgUserByOrgIdAsync(org.Id, user, _userService); + + // Assert + AssertValidAcceptedOrgUser(resultOrgUser, orgUser, user); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserByOrgId_InvalidOrg_ThrowsBadRequest(SutProvider sutProvider, + Guid orgId, User user) + { + // Arrange + + sutProvider.GetDependency() + .GetByIdAsync(orgId) + .Returns((Organization)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptOrgUserByOrgIdAsync(orgId, user, _userService)); + + Assert.Equal("Organization invalid.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserByOrgId_UserNotInOrg_ThrowsBadRequest(SutProvider sutProvider, + Organization org, User user) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(org.Id) + .Returns(org); + + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns((OrganizationUser)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptOrgUserByOrgIdAsync(org.Id, user, _userService)); + + Assert.Equal("User not found within organization.", exception.Message); + } + + // Private helpers ------------------------------------------------------------------------------------------------- + + /// + /// Asserts that the given org user is in the expected state after a successful AcceptOrgUserAsync call. + /// For use in happy path tests. + /// + private void AssertValidAcceptedOrgUser(OrganizationUser resultOrgUser, OrganizationUser expectedOrgUser, User user) + { + Assert.NotNull(resultOrgUser); + Assert.Equal(OrganizationUserStatusType.Accepted, resultOrgUser.Status); + Assert.Equal(expectedOrgUser, resultOrgUser); + Assert.Equal(expectedOrgUser.Id, resultOrgUser.Id); + Assert.Null(resultOrgUser.Email); + Assert.Equal(user.Id, resultOrgUser.UserId); + + + } + + private void SetupCommonAcceptOrgUserByTokenMocks(SutProvider sutProvider, User user, OrganizationUser orgUser) + { + sutProvider.GetDependency().OrganizationInviteExpirationHours.Returns(24); + user.EmailVerified = false; + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.Id) + .Returns(Task.FromResult(orgUser)); + + sutProvider.GetDependency() + .GetCountByOrganizationAsync(orgUser.OrganizationId, user.Email, true) + .Returns(0); + } + + /// + /// Sets up common mock behavior for the AcceptOrgUserAsync tests. + /// This method initializes: + /// - The invited user's email, status, type, and organization ID. + /// - Ensures the user is not part of any other organizations. + /// - Confirms the target organization doesn't have a single org policy. + /// - Ensures the user doesn't belong to an organization with a single org policy. + /// - Assumes the user doesn't have 2FA enabled and the organization doesn't require it. + /// - Provides mock data for an admin to validate email functionality. + /// - Returns the corresponding organization for the given org ID. + /// + private void SetupCommonAcceptOrgUserMocks(SutProvider sutProvider, User user, + Organization org, + OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + orgUser.Email = user.Email; + orgUser.Status = OrganizationUserStatusType.Invited; + orgUser.Type = OrganizationUserType.User; + orgUser.OrganizationId = org.Id; + + // User is not part of any other orgs + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns( + Task.FromResult>(new List()) + ); + + // Org they are trying to join does not have single org policy + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg, OrganizationUserStatusType.Invited) + .Returns( + Task.FromResult>( + new List() + ) + ); + + // User is not part of any organization that applies the single org policy + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg) + .Returns(false); + + // User doesn't have 2FA enabled + _userService.TwoFactorIsEnabledAsync(user).Returns(false); + + // Org does not require 2FA + sutProvider.GetDependency().GetPoliciesApplicableToUserAsync(user.Id, + PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited) + .Returns(Task.FromResult>( + new List())); + + // Provide at least 1 admin to test email functionality + sutProvider.GetDependency() + .GetManyByMinimumRoleAsync(orgUser.OrganizationId, OrganizationUserType.Admin) + .Returns(Task.FromResult>( + new List() { adminUserDetails } + )); + + // Return org + sutProvider.GetDependency() + .GetByIdAsync(org.Id) + .Returns(Task.FromResult(org)); + } + + + private string CreateOldToken(SutProvider sutProvider, + OrganizationUser organizationUser) + { + var dataProtector = sutProvider.GetDependency() + .CreateProtector("OrganizationServiceDataProtector"); + + // Token matching the format used in OrganizationService.InviteUserAsync + var oldToken = dataProtector.Protect( + $"OrganizationUserInvite {organizationUser.Id} {organizationUser.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + return oldToken; + } + + private string CreateNewToken(OrganizationUser orgUser) + { + var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser); + var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable); + + return protectedToken; + } +} diff --git a/test/Core.Test/Services/OrganizationServiceTests.cs b/test/Core.Test/Services/OrganizationServiceTests.cs index a0463c154..a7428697f 100644 --- a/test/Core.Test/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/Services/OrganizationServiceTests.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Business; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Context; @@ -23,13 +24,14 @@ using Bit.Core.Settings; using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; using Bit.Core.Test.AutoFixture.PolicyFixtures; +using Bit.Core.Tokens; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.DataProtection; +using Bit.Test.Common.Fakes; using NSubstitute; using NSubstitute.ExceptionExtensions; using Xunit; @@ -42,10 +44,16 @@ namespace Bit.Core.Test.Services; [SutProviderCustomize] public class OrganizationServiceTests { + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory(); + [Theory, PaidOrganizationCustomize, BitAutoData] public async Task OrgImportCreateNewUsers(SutProvider sutProvider, Guid userId, Organization org, List existingUsers, List newUsers) { + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + org.UseDirectory = true; org.Seats = 10; newUsers.Add(new ImportedOrganizationUser @@ -66,6 +74,16 @@ public class OrganizationServiceTests .Returns(existingUsers.Select(u => new OrganizationUser { Status = OrganizationUserStatusType.Confirmed, Type = OrganizationUserType.Owner, Id = u.Id }).ToList()); sutProvider.GetDependency().ManageUsers(org.Id).Returns(true); + // Mock tokenable factory to return a token that expires in 5 days + sutProvider.GetDependency() + .CreateToken(Arg.Any()) + .Returns( + info => new OrgUserInviteTokenable(info.Arg()) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + } + ); + await sutProvider.Sut.ImportAsync(org.Id, userId, null, newUsers, null, false); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() @@ -97,6 +115,10 @@ public class OrganizationServiceTests Guid userId, Organization org, List existingUsers, List newUsers) { + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + org.UseDirectory = true; org.Seats = newUsers.Count + existingUsers.Count + 1; var reInvitedUser = existingUsers.First(); @@ -120,6 +142,16 @@ public class OrganizationServiceTests var currentContext = sutProvider.GetDependency(); currentContext.ManageUsers(org.Id).Returns(true); + // Mock tokenable factory to return a token that expires in 5 days + sutProvider.GetDependency() + .CreateToken(Arg.Any()) + .Returns( + info => new OrgUserInviteTokenable(info.Arg()) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + } + ); + await sutProvider.Sut.ImportAsync(org.Id, userId, null, newUsers, null, false); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() @@ -349,6 +381,10 @@ public class OrganizationServiceTests [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, OrganizationUserInvite invite, SutProvider sutProvider) { + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + invite.Emails = invite.Emails.Append(invite.Emails.First()); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); @@ -358,6 +394,16 @@ public class OrganizationServiceTests organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) .Returns(new[] { owner }); + // Mock tokenable factory to return a token that expires in 5 days + sutProvider.GetDependency() + .CreateToken(Arg.Any()) + .Returns( + info => new OrgUserInviteTokenable(info.Arg()) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + } + ); + await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) }); await sutProvider.GetDependency().Received(1) @@ -586,6 +632,10 @@ public class OrganizationServiceTests [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, SutProvider sutProvider) { + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + invitor.Permissions = JsonSerializer.Serialize(new Permissions() { ManageUsers = true }, new JsonSerializerOptions { @@ -621,6 +671,16 @@ public class OrganizationServiceTests } }); + // Mock tokenable factory to return a token that expires in 5 days + sutProvider.GetDependency() + .CreateToken(Arg.Any()) + .Returns( + info => new OrgUserInviteTokenable(info.Arg()) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + } + ); + await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, invites); await sutProvider.GetDependency().Received(1) @@ -640,6 +700,10 @@ public class OrganizationServiceTests [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, SutProvider sutProvider) { + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + invitor.Permissions = JsonSerializer.Serialize(new Permissions() { ManageUsers = true }, new JsonSerializerOptions { @@ -655,6 +719,16 @@ public class OrganizationServiceTests .Returns(new[] { owner }); currentContext.ManageUsers(organization.Id).Returns(true); + // Mock tokenable factory to return a token that expires in 5 days + sutProvider.GetDependency() + .CreateToken(Arg.Any()) + .Returns( + info => new OrgUserInviteTokenable(info.Arg()) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + } + ); + await sutProvider.Sut.InviteUsersAsync(organization.Id, eventSystemUser, invites); await sutProvider.GetDependency().Received(1) @@ -1865,42 +1939,6 @@ public class OrganizationServiceTests sutProvider.Sut.ValidateSecretsManagerPlan(plan, signup); } - [Theory] - [EphemeralDataProtectionAutoData] - public async Task AcceptUserAsync_Success([OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser organizationUser, - User user, SutProvider sutProvider) - { - var token = SetupAcceptUserAsyncTest(sutProvider, user, organizationUser); - var userService = Substitute.For(); - - await sutProvider.Sut.AcceptUserAsync(organizationUser.Id, user, token, userService); - - await sutProvider.GetDependency().Received(1).ReplaceAsync( - Arg.Is(ou => ou.Id == organizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted)); - await sutProvider.GetDependency().Received(1).ReplaceAsync( - Arg.Is(u => u.Id == user.Id && u.Email == user.Email && user.EmailVerified == true)); - } - - private string SetupAcceptUserAsyncTest(SutProvider sutProvider, User user, - OrganizationUser organizationUser) - { - user.Email = organizationUser.Email; - user.EmailVerified = false; - - var dataProtector = sutProvider.GetDependency() - .CreateProtector("OrganizationServiceDataProtector"); - // Token matching the format used in OrganizationService.InviteUserAsync - var token = dataProtector.Protect( - $"OrganizationUserInvite {organizationUser.Id} {organizationUser.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - - sutProvider.GetDependency().OrganizationInviteExpirationHours.Returns(24); - - sutProvider.GetDependency().GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - return token; - } - [Theory] [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.Owner, diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 7df36855a..cbedea4a7 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -10,6 +10,7 @@ using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -18,6 +19,7 @@ using Bit.Core.Tools.Services; using Bit.Core.Vault.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Fakes; using Bit.Test.Common.Helpers; using Fido2NetLib; using Microsoft.AspNetCore.DataProtection; @@ -272,9 +274,10 @@ public class UserServiceTests sutProvider.GetDependency(), sutProvider.GetDependency(), sutProvider.GetDependency(), - sutProvider.GetDependency(), + sutProvider.GetDependency(), sutProvider.GetDependency(), sutProvider.GetDependency(), + new FakeDataProtectorTokenFactory(), sutProvider.GetDependency(), sutProvider.GetDependency>() );