diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 6354df669f..73140cea4e 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 6f93c22def..60257814e9 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 9c078d7b28..6339725cd9 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 804aeb6518..8be36522ab 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 01a1fbb554..ac7ef9acdc 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 0000000000..04d3b008b1 --- /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 0000000000..95c84ad3b5 --- /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 0000000000..9e5a797dbb --- /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 ea8347653b..24a74bde07 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 0000000000..31dd19d5bf --- /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 0000000000..32966f5710 --- /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 0000000000..eff162c739 --- /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 e3a3bfeaeb..e70738e06a 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 0000000000..5baff54634 --- /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 0000000000..14cabda521 --- /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 044c4f1470..6da6d8fd73 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 e276689466..3340ec6a70 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 d3d341fd91..73eb98cd0c 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 3f29d14afb..abe01d2e9a 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 089405e536..5e90a24066 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 b125fba49e..b44283f6e9 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 e49bf91921..c12c849e37 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 4b5c633cbe..b62027919c 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 0bd368790e..491f5a9f49 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 0000000000..0d2f004b17 --- /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 0000000000..fe3af320d6 --- /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 0000000000..aeeda206fa --- /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 0000000000..a352976438 --- /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 0000000000..33ad63d02e --- /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 a0463c1548..a7428697f6 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 7df36855a7..cbedea4a7a 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>() );