mirror of
https://github.com/bitwarden/server.git
synced 2025-07-01 16:12:49 -05:00
Merge branch 'master' into flexible-collections/deprecate-custom-collection-perm
# Conflicts: # src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
This commit is contained in:
@ -14,6 +14,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;
|
||||
@ -36,6 +37,7 @@ public class OrganizationUsersController : Controller
|
||||
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||
private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand;
|
||||
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
|
||||
@ -53,6 +55,7 @@ public class OrganizationUsersController : Controller
|
||||
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
|
||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand,
|
||||
IAcceptOrgUserCommand acceptOrgUserCommand,
|
||||
IFeatureService featureService,
|
||||
IAuthorizationService authorizationService)
|
||||
{
|
||||
@ -67,6 +70,7 @@ public class OrganizationUsersController : Controller
|
||||
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
||||
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
||||
_updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand;
|
||||
_acceptOrgUserCommand = acceptOrgUserCommand;
|
||||
_featureService = featureService;
|
||||
_authorizationService = authorizationService;
|
||||
}
|
||||
@ -212,7 +216,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);
|
||||
}
|
||||
|
||||
@ -234,7 +238,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)
|
||||
{
|
||||
@ -345,7 +349,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
|
||||
public PoliciesController(
|
||||
IPolicyRepository policyRepository,
|
||||
@ -35,7 +38,8 @@ public class PoliciesController : Controller
|
||||
IUserService userService,
|
||||
ICurrentContext currentContext,
|
||||
GlobalSettings globalSettings,
|
||||
IDataProtectionProvider dataProtectionProvider)
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> 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<ListResponseModel<PolicyResponseModel>> GetByToken(string orgId, [FromQuery] string email,
|
||||
[FromQuery] string token, [FromQuery] string organizationUserId)
|
||||
public async Task<ListResponseModel<PolicyResponseModel>> 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<PolicyResponseModel>(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<ListResponseModel<PolicyResponseModel>> GetByInvitedUser(string orgId, [FromQuery] string userId)
|
||||
public async Task<ListResponseModel<PolicyResponseModel>> 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<PolicyResponseModel>(responses);
|
||||
}
|
||||
|
||||
[HttpGet("master-password")]
|
||||
public async Task<PolicyResponseModel> 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<PolicyResponseModel> Put(string orgId, int type, [FromBody] PolicyRequestModel model)
|
||||
{
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -0,0 +1,8 @@
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Business.Tokenables;
|
||||
|
||||
public interface IOrgUserInviteTokenableFactory
|
||||
{
|
||||
OrgUserInviteTokenable CreateToken(OrganizationUser orgUser);
|
||||
}
|
@ -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<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
string orgUserInviteToken, OrganizationUser orgUser)
|
||||
{
|
||||
return orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken)
|
||||
&& decryptedToken.Valid
|
||||
&& decryptedToken.TokenIsValid(orgUser);
|
||||
}
|
||||
|
||||
public static bool ValidateOrgUserInviteStringToken(
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
string orgUserInviteToken, Guid orgUserId, string orgUserEmail)
|
||||
{
|
||||
return orgUserInviteTokenDataFactory.TryUnprotect(orgUserInviteToken, out var decryptedToken)
|
||||
&& decryptedToken.Valid
|
||||
&& decryptedToken.TokenIsValid(orgUserId, orgUserEmail);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -0,0 +1,19 @@
|
||||
using Bit.Core.Entities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// <para>Manages the setting of the initial master password for a <see cref="User"/> in an organization.</para>
|
||||
/// <para>This class is primarily invoked in two scenarios:</para>
|
||||
/// <para>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.</para>
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public interface ISetInitialMasterPasswordCommand
|
||||
{
|
||||
public Task<IdentityResult> SetInitialMasterPasswordAsync(User user, string masterPassword, string key,
|
||||
string orgSsoIdentifier);
|
||||
}
|
@ -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<SetInitialMasterPasswordCommand> _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<SetInitialMasterPasswordCommand> 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<IdentityResult> 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;
|
||||
}
|
||||
|
||||
}
|
@ -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<IUserService, UserService>();
|
||||
services.AddUserPasswordCommands();
|
||||
}
|
||||
|
||||
private static void AddUserPasswordCommands(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<ISetInitialMasterPasswordCommand, SetInitialMasterPasswordCommand>();
|
||||
}
|
||||
|
||||
}
|
@ -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<ICountNewSmSeatsRequiredQuery, CountNewSmSeatsRequiredQuery>();
|
||||
services.AddScoped<IAcceptOrgUserCommand, AcceptOrgUserCommand>();
|
||||
}
|
||||
|
||||
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of
|
||||
|
@ -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<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
|
||||
public AcceptOrgUserCommand(
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IGlobalSettings globalSettings,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPolicyService policyService,
|
||||
IMailService mailService,
|
||||
IUserRepository userRepository,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> 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<OrganizationUser> 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<OrganizationUser> 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<OrganizationUser> 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<OrganizationUser> 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;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
|
||||
public interface IAcceptOrgUserCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="emailToken">The token embedded in the email invitation link</param>
|
||||
/// <returns>The accepted OrganizationUser.</returns>
|
||||
Task<OrganizationUser> AcceptOrgUserByEmailTokenAsync(Guid organizationUserId, User user, string emailToken, IUserService userService);
|
||||
Task<OrganizationUser> AcceptOrgUserByOrgSsoIdAsync(string orgIdentifier, User user, IUserService userService);
|
||||
Task<OrganizationUser> AcceptOrgUserByOrgIdAsync(Guid organizationId, User user, IUserService userService);
|
||||
Task<OrganizationUser> AcceptOrgUserAsync(OrganizationUser orgUser, User user, IUserService userService);
|
||||
}
|
@ -40,15 +40,6 @@ public interface IOrganizationService
|
||||
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<CollectionAccessSelection> collections, IEnumerable<Guid> groups);
|
||||
Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId);
|
||||
Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false);
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="token">The token embedded in the email invitation link</param>
|
||||
/// <returns>The accepted OrganizationUser.</returns>
|
||||
Task<OrganizationUser> AcceptUserAsync(Guid organizationUserId, User user, string token, IUserService userService);
|
||||
Task<OrganizationUser> AcceptUserAsync(string orgIdentifier, User user, IUserService userService);
|
||||
Task<OrganizationUser> AcceptUserAsync(Guid organizationId, User user, IUserService userService);
|
||||
Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
|
||||
Guid confirmingUserId, IUserService userService);
|
||||
Task<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync(Guid organizationId, Dictionary<Guid, string> keys,
|
||||
|
@ -37,7 +37,6 @@ public interface IUserService
|
||||
Task<IdentityResult> ChangeEmailAsync(User user, string masterPassword, string newEmail, string newMasterPassword,
|
||||
string token, string key);
|
||||
Task<IdentityResult> ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint, string key);
|
||||
Task<IdentityResult> SetPasswordAsync(User user, string newMasterPassword, string key, string orgIdentifier = null);
|
||||
Task<IdentityResult> SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier);
|
||||
Task<IdentityResult> ConvertToKeyConnectorAsync(User user);
|
||||
Task<IdentityResult> AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key);
|
||||
@ -78,6 +77,9 @@ public interface IUserService
|
||||
Task<bool> TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user);
|
||||
Task<bool> TwoFactorProviderIsEnabledAsync(TwoFactorProviderType provider, ITwoFactorProvidersUser user);
|
||||
Task<string> GenerateSignInTokenAsync(User user, string purpose);
|
||||
|
||||
Task<IdentityResult> UpdatePasswordHash(User user, string newPassword,
|
||||
bool validatePassword = true, bool refreshStamp = true);
|
||||
Task RotateApiKeyAsync(User user);
|
||||
string GetUserName(ClaimsPrincipal principal);
|
||||
Task SendOTPAsync(User user);
|
||||
|
@ -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<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
private bool UseFlexibleCollections => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
|
||||
@ -67,7 +69,6 @@ public class OrganizationService : IOrganizationService
|
||||
ICollectionRepository collectionRepository,
|
||||
IUserRepository userRepository,
|
||||
IGroupRepository groupRepository,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IMailService mailService,
|
||||
IPushNotificationService pushNotificationService,
|
||||
IPushRegistrationService pushRegistrationService,
|
||||
@ -88,6 +89,8 @@ public class OrganizationService : IOrganizationService
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
|
||||
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
@ -96,7 +99,6 @@ public class OrganizationService : IOrganizationService
|
||||
_collectionRepository = collectionRepository;
|
||||
_userRepository = userRepository;
|
||||
_groupRepository = groupRepository;
|
||||
_dataProtector = dataProtectionProvider.CreateProtector("OrganizationServiceDataProtector");
|
||||
_mailService = mailService;
|
||||
_pushNotificationService = pushNotificationService;
|
||||
_pushRegistrationService = pushRegistrationService;
|
||||
@ -118,6 +120,8 @@ public class OrganizationService : IOrganizationService
|
||||
_providerUserRepository = providerUserRepository;
|
||||
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
||||
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
||||
_orgUserInviteTokenableFactory = orgUserInviteTokenableFactory;
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
@ -1066,176 +1070,33 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> 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<OrganizationUser> 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<OrganizationUser> 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<OrganizationUser> 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<OrganizationUser> 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<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
|
||||
|
@ -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<User>, 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<WebAuthnLoginTokenable> _webAuthnLoginTokenizer;
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
|
||||
public UserService(
|
||||
IUserRepository userRepository,
|
||||
@ -90,9 +92,10 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
IFido2 fido2,
|
||||
ICurrentContext currentContext,
|
||||
IGlobalSettings globalSettings,
|
||||
IOrganizationService organizationService,
|
||||
IAcceptOrgUserCommand acceptOrgUserCommand,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IStripeSyncService stripeSyncService,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IWebAuthnCredentialRepository webAuthnRepository,
|
||||
IDataProtectorTokenFactory<WebAuthnLoginTokenable> webAuthnLoginTokenizer)
|
||||
: base(
|
||||
@ -128,9 +131,10 @@ public class UserService : UserManager<User>, 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<User>, 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<User>, IUserService, IDisposable
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
|
||||
public override Task<IdentityResult> ChangePasswordAsync(User user, string masterPassword, string newMasterPassword)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async Task<IdentityResult> ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint,
|
||||
string key)
|
||||
{
|
||||
@ -768,40 +772,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
|
||||
}
|
||||
|
||||
public async Task<IdentityResult> 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<IdentityResult> SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier)
|
||||
{
|
||||
var identityResult = CheckCanUseKeyConnector(user);
|
||||
@ -817,7 +787,7 @@ public class UserService : UserManager<User>, 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<User>, IUserService, IDisposable
|
||||
return token;
|
||||
}
|
||||
|
||||
private async Task<IdentityResult> UpdatePasswordHash(User user, string newPassword,
|
||||
public async Task<IdentityResult> UpdatePasswordHash(User user, string newPassword,
|
||||
bool validatePassword = true, bool refreshStamp = true)
|
||||
{
|
||||
if (validatePassword)
|
||||
|
@ -7,7 +7,16 @@ public abstract class ExpiringTokenable : Tokenable
|
||||
{
|
||||
[JsonConverter(typeof(EpochDateTimeJsonConverter))]
|
||||
public DateTime ExpirationDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the token is still within its valid duration and if its data is valid.
|
||||
/// <para>For data validation, this property relies on the <see cref="TokenIsValid"/> method.</para>
|
||||
/// </summary>
|
||||
public override bool Valid => ExpirationDate > DateTime.UtcNow && TokenIsValid();
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the token data properties are correct.
|
||||
/// <para>For expiration checks, refer to the <see cref="Valid"/> property.</para>
|
||||
/// </summary>
|
||||
protected abstract bool TokenIsValid();
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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<ICipherService, CipherService>();
|
||||
services.AddScoped<IUserService, UserService>();
|
||||
services.AddUserServices(globalSettings);
|
||||
services.AddOrganizationServices(globalSettings);
|
||||
services.AddScoped<ICollectionService, CollectionService>();
|
||||
services.AddScoped<IGroupService, GroupService>();
|
||||
@ -152,6 +153,7 @@ public static class ServiceCollectionExtensions
|
||||
serviceProvider.GetDataProtectionProvider(),
|
||||
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<EmergencyAccessInviteTokenable>>>())
|
||||
);
|
||||
|
||||
services.AddSingleton<IDataProtectorTokenFactory<HCaptchaTokenable>>(serviceProvider =>
|
||||
new DataProtectorTokenFactory<HCaptchaTokenable>(
|
||||
HCaptchaTokenable.ClearTextPrefix,
|
||||
@ -159,6 +161,7 @@ public static class ServiceCollectionExtensions
|
||||
serviceProvider.GetDataProtectionProvider(),
|
||||
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<HCaptchaTokenable>>>())
|
||||
);
|
||||
|
||||
services.AddSingleton<IDataProtectorTokenFactory<SsoTokenable>>(serviceProvider =>
|
||||
new DataProtectorTokenFactory<SsoTokenable>(
|
||||
SsoTokenable.ClearTextPrefix,
|
||||
@ -183,6 +186,14 @@ public static class ServiceCollectionExtensions
|
||||
SsoEmail2faSessionTokenable.DataProtectorPurpose,
|
||||
serviceProvider.GetDataProtectionProvider(),
|
||||
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<SsoEmail2faSessionTokenable>>>()));
|
||||
|
||||
services.AddSingleton<IOrgUserInviteTokenableFactory, OrgUserInviteTokenableFactory>();
|
||||
services.AddSingleton<IDataProtectorTokenFactory<OrgUserInviteTokenable>>(serviceProvider =>
|
||||
new DataProtectorTokenFactory<OrgUserInviteTokenable>(
|
||||
OrgUserInviteTokenable.ClearTextPrefix,
|
||||
OrgUserInviteTokenable.DataProtectorPurpose,
|
||||
serviceProvider.GetDataProtectionProvider(),
|
||||
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<OrgUserInviteTokenable>>>()));
|
||||
}
|
||||
|
||||
public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings)
|
||||
|
@ -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<IOrganizationService>().Received(1).AcceptUserAsync(orgId, user, sutProvider.GetDependency<IUserService>());
|
||||
await sutProvider.GetDependency<IAcceptOrgUserCommand>().Received(1).AcceptOrgUserByOrgIdAsync(orgId, user, sutProvider.GetDependency<IUserService>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@ -41,7 +42,7 @@ public class OrganizationUsersControllerTests
|
||||
|
||||
await sutProvider.Sut.PutResetPasswordEnrollment(orgId, userId, model);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(0).AcceptUserAsync(orgId, user, sutProvider.GetDependency<IUserService>());
|
||||
await sutProvider.GetDependency<IAcceptOrgUserCommand>().Received(0).AcceptOrgUserByOrgIdAsync(orgId, user, sutProvider.GetDependency<IUserService>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@ -63,8 +64,8 @@ public class OrganizationUsersControllerTests
|
||||
|
||||
await sutProvider.Sut.Accept(orgId, orgUserId, model);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1)
|
||||
.AcceptUserAsync(orgUserId, user, model.Token, sutProvider.GetDependency<IUserService>());
|
||||
await sutProvider.GetDependency<IAcceptOrgUserCommand>().Received(1)
|
||||
.AcceptOrgUserByEmailTokenAsync(orgUserId, user, model.Token, sutProvider.GetDependency<IUserService>());
|
||||
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs()
|
||||
.UpdateUserResetPasswordEnrollmentAsync(default, default, default, default);
|
||||
}
|
||||
@ -85,8 +86,8 @@ public class OrganizationUsersControllerTests
|
||||
|
||||
await sutProvider.Sut.Accept(orgId, orgUserId, model);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1)
|
||||
.AcceptUserAsync(orgUserId, user, model.Token, sutProvider.GetDependency<IUserService>());
|
||||
await sutProvider.GetDependency<IAcceptOrgUserCommand>().Received(1)
|
||||
.AcceptOrgUserByEmailTokenAsync(orgUserId, user, model.Token, sutProvider.GetDependency<IUserService>());
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1)
|
||||
.UpdateUserResetPasswordEnrollmentAsync(orgId, user.Id, model.ResetPasswordKey, user.Id);
|
||||
}
|
||||
|
@ -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<ISendService>();
|
||||
_captchaValidationService = Substitute.For<ICaptchaValidationService>();
|
||||
_policyService = Substitute.For<IPolicyService>();
|
||||
_setInitialMasterPasswordCommand = Substitute.For<ISetInitialMasterPasswordCommand>();
|
||||
|
||||
_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<ClaimsPrincipal>()).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<User>(u => u == user),
|
||||
Arg.Is<string>(s => s == setPasswordRequestModel.MasterPasswordHash),
|
||||
Arg.Is<string>(s => s == setPasswordRequestModel.Key),
|
||||
Arg.Is<string>(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<ClaimsPrincipal>()).Returns(Task.FromResult((User)null));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => _sut.PostSetPasswordAsync(setPasswordRequestModel));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetPasswordAsync_WhenSettingPasswordFails_ShouldThrowBadRequestException(
|
||||
User user,
|
||||
SetPasswordRequestModel model)
|
||||
{
|
||||
// Arrange
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
|
||||
_setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(IdentityResult.Failed(new IdentityError { Description = "Some Error" })));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BadRequestException>(() => _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
|
||||
|
133
test/Api.Test/Controllers/PoliciesControllerTests.cs
Normal file
133
test/Api.Test/Controllers/PoliciesControllerTests.cs
Normal file
@ -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<PoliciesController> sutProvider, Guid orgId, Guid userId, OrganizationUser orgUser,
|
||||
Policy policy, MasterPasswordPolicyData mpPolicyData)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns((Guid?)userId);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.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<IPolicyRepository>()
|
||||
.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<string, object>
|
||||
// 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<PoliciesController> sutProvider, Guid orgId, Guid userId)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns((Guid?)userId);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(orgId, userId)
|
||||
.Returns((OrganizationUser)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.GetMasterPasswordPolicy(orgId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetMasterPasswordPolicy_PolicyIsNull_ThrowsNotFoundException(
|
||||
SutProvider<PoliciesController> sutProvider, Guid orgId, Guid userId, OrganizationUser orgUser)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns((Guid?)userId);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(orgId, userId)
|
||||
.Returns(orgUser);
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(orgId, PolicyType.MasterPassword)
|
||||
.Returns((Policy)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.GetMasterPasswordPolicy(orgId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetMasterPasswordPolicy_PolicyNotEnabled_ThrowsNotFoundException(
|
||||
SutProvider<PoliciesController> sutProvider, Guid orgId, Guid userId, OrganizationUser orgUser, Policy policy)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns((Guid?)userId);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(orgId, userId)
|
||||
.Returns(orgUser);
|
||||
|
||||
policy.Enabled = false; // Ensuring the policy is not enabled
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(orgId, PolicyType.MasterPassword)
|
||||
.Returns(policy);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.GetMasterPasswordPolicy(orgId));
|
||||
}
|
||||
}
|
55
test/Common/Fakes/FakeDataProtectorTokenFactory.cs
Normal file
55
test/Common/Fakes/FakeDataProtectorTokenFactory.cs
Normal file
@ -0,0 +1,55 @@
|
||||
using Bit.Core.Tokens;
|
||||
|
||||
namespace Bit.Test.Common.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Used to fake the IDataProtectorTokenFactory for testing purposes.
|
||||
/// Generalized for use with all Tokenables.
|
||||
/// </summary>
|
||||
public class FakeDataProtectorTokenFactory<T> : IDataProtectorTokenFactory<T> where T : Tokenable, new()
|
||||
{
|
||||
// Instead of real encryption, use a simple Dictionary to emulate protection/unprotection
|
||||
private readonly Dictionary<string, T> _tokenDatabase = new Dictionary<string, T>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the default constructor sets the expiration date to the expected duration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_DefaultInitialization_ExpirationSetToExpectedDuration()
|
||||
{
|
||||
var token = new OrgUserInviteTokenable();
|
||||
var expectedExpiration = DateTime.UtcNow + OrgUserInviteTokenable.GetTokenLifetime();
|
||||
|
||||
Assert.True(TimesAreCloseEnough(expectedExpiration, token.ExpirationDate, _timeTolerance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the constructor sets the properties correctly from a valid OrganizationUser object.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the constructor sets the properties to default values when given a null OrganizationUser object.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullOrgUser_PropertiesSetToDefault()
|
||||
{
|
||||
var token = new OrgUserInviteTokenable(null);
|
||||
|
||||
Assert.Equal(default, token.OrgUserId);
|
||||
Assert.Equal(default, token.OrgUserEmail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that a custom expiration date is preserved after token initialization.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the validity of a token initialized with a null org user.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Valid_NullOrgUser_ReturnsFalse()
|
||||
{
|
||||
var token = new OrgUserInviteTokenable(null);
|
||||
|
||||
Assert.False(token.Valid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the validity of a token with a non-matching identifier.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Valid_WrongIdentifier_ReturnsFalse()
|
||||
{
|
||||
var token = new OrgUserInviteTokenable
|
||||
{
|
||||
Identifier = "IncorrectIdentifier"
|
||||
};
|
||||
|
||||
Assert.False(token.Valid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the validity of the token when the OrgUserId is set to default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Valid_DefaultOrgUserId_ReturnsFalse()
|
||||
{
|
||||
var token = new OrgUserInviteTokenable
|
||||
{
|
||||
OrgUserId = default // Guid.Empty
|
||||
};
|
||||
|
||||
Assert.False(token.Valid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the validity of the token when the OrgUserEmail is null or empty.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void Valid_NullOrEmptyOrgUserEmail_ReturnsFalse(string email)
|
||||
{
|
||||
var token = new OrgUserInviteTokenable
|
||||
{
|
||||
OrgUserEmail = email
|
||||
};
|
||||
|
||||
Assert.False(token.Valid);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Tests the validity of the token when the token is expired.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Valid_ExpiredToken_ReturnsFalse()
|
||||
{
|
||||
var expiredDate = DateTime.UtcNow.AddHours(-3);
|
||||
var token = new OrgUserInviteTokenable
|
||||
{
|
||||
ExpirationDate = expiredDate
|
||||
};
|
||||
|
||||
Assert.False(token.Valid);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Tests the TokenIsValid method when given a null OrganizationUser object.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TokenIsValid_NullOrgUser_ReturnsFalse()
|
||||
{
|
||||
var token = new OrgUserInviteTokenable(null);
|
||||
|
||||
Assert.False(token.TokenIsValid(null));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the TokenIsValid method when the OrgUserId does not match.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the TokenIsValid method when the OrgUserEmail does not match.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the TokenIsValid method when both OrgUserId and OrgUserEmail match.
|
||||
/// </summary>
|
||||
[Theory, AutoData]
|
||||
public void TokenIsValid_MatchingUserIdAndEmail_ReturnsTrue(OrganizationUser orgUser)
|
||||
{
|
||||
var token = new OrgUserInviteTokenable(orgUser);
|
||||
|
||||
Assert.True(token.TokenIsValid(orgUser));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the TokenIsValid method to ensure email comparison is case-insensitive.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Tests the TokenIsValid method when the token is expired.
|
||||
/// Should return true as TokenIsValid only validates token data -- not token expiration.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the deserialization of a token to ensure that the ExpirationDate is preserved.
|
||||
/// </summary>
|
||||
[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<OrgUserInviteTokenable>(token.ToToken());
|
||||
|
||||
Assert.True(TimesAreCloseEnough(expectedDateTime, result.ExpirationDate, _timeTolerance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the deserialization of a token to ensure that the OrgUserId property is preserved.
|
||||
/// </summary>
|
||||
[Theory, AutoData]
|
||||
public void FromToken_SerializedToken_PreservesOrgUserId(OrganizationUser orgUser)
|
||||
{
|
||||
var token = new OrgUserInviteTokenable(orgUser);
|
||||
var result = Tokenable.FromToken<OrgUserInviteTokenable>(token.ToToken());
|
||||
Assert.Equal(orgUser.Id, result.OrgUserId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the deserialization of a token to ensure that the OrgUserEmail property is preserved.
|
||||
/// </summary>
|
||||
[Theory, AutoData]
|
||||
public void FromToken_SerializedToken_PreservesOrgUserEmail(OrganizationUser orgUser)
|
||||
{
|
||||
var token = new OrgUserInviteTokenable(orgUser);
|
||||
var result = Tokenable.FromToken<OrgUserInviteTokenable>(token.ToToken());
|
||||
Assert.Equal(orgUser.Email, result.OrgUserEmail);
|
||||
}
|
||||
|
||||
private bool TimesAreCloseEnough(DateTime time1, DateTime time2, TimeSpan tolerance)
|
||||
{
|
||||
return (time1 - time2).Duration() < tolerance;
|
||||
}
|
||||
}
|
@ -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<SetInitialMasterPasswordCommand> sutProvider,
|
||||
User user, string masterPassword, string key, string orgIdentifier,
|
||||
Organization org, OrganizationUser orgUser)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(orgIdentifier)
|
||||
.Returns(org);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.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<SetInitialMasterPasswordCommand> sutProvider, string masterPassword, string key, string orgIdentifier)
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(null, masterPassword, key, orgIdentifier));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_AlreadyHasPassword_ReturnsFalse(SutProvider<SetInitialMasterPasswordCommand> 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<SetInitialMasterPasswordCommand> sutProvider, User user, string masterPassword, string key)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
string orgSsoIdentifier = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
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<SetInitialMasterPasswordCommand> sutProvider, User user, string masterPassword, string key, string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(orgIdentifier)
|
||||
.ReturnsNull();
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier));
|
||||
Assert.Equal("Organization invalid.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_UserNotFoundInOrganization_Throws(SutProvider<SetInitialMasterPasswordCommand> sutProvider, User user, string masterPassword, string key, Organization org)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(Arg.Any<string>())
|
||||
.Returns(org);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(org.Id, user.Id)
|
||||
.ReturnsNull();
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(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<SetInitialMasterPasswordCommand> sutProvider,
|
||||
User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(orgIdentifier)
|
||||
.Returns(org);
|
||||
|
||||
orgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.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<IAcceptOrgUserCommand>().DidNotReceive().AcceptOrgUserAsync(Arg.Any<OrganizationUser>(), Arg.Any<User>(), Arg.Any<IUserService>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_InvitedOrgUser_CallsAcceptOrgUser(SutProvider<SetInitialMasterPasswordCommand> sutProvider,
|
||||
User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(orgIdentifier)
|
||||
.Returns(org);
|
||||
|
||||
orgUser.Status = OrganizationUserStatusType.Invited;
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.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<IAcceptOrgUserCommand>().Received(1).AcceptOrgUserAsync(orgUser, user, sutProvider.GetDependency<IUserService>());
|
||||
}
|
||||
|
||||
}
|
@ -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<IUserService>();
|
||||
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory = Substitute.For<IOrgUserInviteTokenableFactory>();
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>();
|
||||
|
||||
// Base AcceptOrgUserAsync method tests ----------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task AcceptOrgUser_InvitedUserToSingleOrg_AcceptsOrgUser(
|
||||
SutProvider<AcceptOrgUserCommand> 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<IOrganizationUserRepository>().Received(1).ReplaceAsync(
|
||||
Arg.Is<OrganizationUser>(ou => ou.Id == orgUser.Id && ou.Status == OrganizationUserStatusType.Accepted));
|
||||
|
||||
// Verify emails sent to admin
|
||||
await sutProvider.GetDependency<IMailService>().Received(1).SendOrganizationAcceptedEmailAsync(
|
||||
Arg.Is<Organization>(o => o.Id == org.Id),
|
||||
Arg.Is<string>(e => e == user.Email),
|
||||
Arg.Is<IEnumerable<string>>(a => a.Contains(adminUserDetails.Email))
|
||||
);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task AcceptOrgUser_OrgUserStatusIsRevoked_ReturnsBadRequest(
|
||||
SutProvider<AcceptOrgUserCommand> 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<BadRequestException>(() =>
|
||||
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<AcceptOrgUserCommand> 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<BadRequestException>(() =>
|
||||
sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService));
|
||||
|
||||
Assert.Equal("Already accepted.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task AcceptOrgUser_UserJoiningOrgWithSingleOrgPolicyWhileInAnotherOrg_ThrowsBadRequest(
|
||||
SutProvider<AcceptOrgUserCommand> 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<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(user.Id)
|
||||
.Returns(Task.FromResult<ICollection<OrganizationUser>>(new List<OrganizationUser> { otherOrgUser }));
|
||||
|
||||
// Make organization they are trying to join have the single org policy
|
||||
var singleOrgPolicy = new OrganizationUserPolicyDetails { OrganizationId = orgUser.OrganizationId };
|
||||
sutProvider.GetDependency<IPolicyService>()
|
||||
.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg, OrganizationUserStatusType.Invited)
|
||||
.Returns(Task.FromResult<ICollection<OrganizationUserPolicyDetails>>(
|
||||
new List<OrganizationUserPolicyDetails> { singleOrgPolicy }));
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
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<AcceptOrgUserCommand> 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<IPolicyService>()
|
||||
.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg)
|
||||
.Returns(true);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
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<AcceptOrgUserCommand> 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<IPolicyService>()
|
||||
.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication,
|
||||
OrganizationUserStatusType.Invited)
|
||||
.Returns(Task.FromResult<ICollection<OrganizationUserPolicyDetails>>(
|
||||
new List<OrganizationUserPolicyDetails> { twoFactorPolicy }));
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
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<AcceptOrgUserCommand> 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<IUserRepository>().Received(1).ReplaceAsync(
|
||||
Arg.Is<User>(u => u.Id == user.Id && u.Email == user.Email && user.EmailVerified == true));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task AcceptOrgUserByToken_NewToken_AcceptsUserAndVerifiesEmail(
|
||||
SutProvider<AcceptOrgUserCommand> 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<IUserRepository>().Received(1).ReplaceAsync(
|
||||
Arg.Is<User>(u => u.Id == user.Id && u.Email == user.Email && user.EmailVerified == true));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task AcceptOrgUserByToken_NullOrgUser_ThrowsBadRequest(
|
||||
SutProvider<AcceptOrgUserCommand> sutProvider,
|
||||
User user, Guid orgUserId)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByIdAsync(orgUserId).Returns((OrganizationUser)null);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUserId, user, "token", _userService));
|
||||
|
||||
Assert.Equal("User invalid.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task AcceptOrgUserByToken_GenericInvalidToken_ThrowsBadRequest(
|
||||
SutProvider<AcceptOrgUserCommand> sutProvider,
|
||||
User user, OrganizationUser orgUser)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByIdAsync(orgUser.Id)
|
||||
.Returns(Task.FromResult(orgUser));
|
||||
|
||||
var invalidToken = "invalidToken";
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUser.Id, user, invalidToken, _userService));
|
||||
|
||||
Assert.Equal("Invalid token.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[EphemeralDataProtectionAutoData]
|
||||
public async Task AcceptOrgUserByToken_ExpiredOldToken_ThrowsBadRequest(
|
||||
SutProvider<AcceptOrgUserCommand> 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<IGlobalSettings>().OrganizationInviteExpirationHours.Returns(-24);
|
||||
|
||||
var oldToken = CreateOldToken(sutProvider, orgUser);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUser.Id, user, oldToken, _userService));
|
||||
|
||||
Assert.Equal("Invalid token.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task AcceptOrgUserByToken_ExpiredNewToken_ThrowsBadRequest(
|
||||
SutProvider<AcceptOrgUserCommand> 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<IOrganizationUserRepository>()
|
||||
.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<BadRequestException>(
|
||||
() => 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<AcceptOrgUserCommand> 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<IOrganizationUserRepository>()
|
||||
.GetByIdAsync(orgUser.Id)
|
||||
.Returns(Task.FromResult(orgUser));
|
||||
|
||||
// Indicate that a user with the given email already exists in the organization
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.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<BadRequestException>(
|
||||
() => sutProvider.Sut.AcceptOrgUserByEmailTokenAsync(orgUser.Id, user, newToken, _userService));
|
||||
|
||||
Assert.Equal(expectedErrorMessage, exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task AcceptOrgUserByToken_EmailMismatch_ThrowsBadRequest(
|
||||
SutProvider<AcceptOrgUserCommand> 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<BadRequestException>(
|
||||
() => 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<AcceptOrgUserCommand> sutProvider,
|
||||
User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails)
|
||||
{
|
||||
// Arrange
|
||||
SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(org.Identifier)
|
||||
.Returns(org);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.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<AcceptOrgUserCommand> sutProvider,
|
||||
string orgSsoIdentifier, User user)
|
||||
{
|
||||
// Arrange
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(orgSsoIdentifier)
|
||||
.Returns((Organization)null);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.AcceptOrgUserByOrgSsoIdAsync(orgSsoIdentifier, user, _userService));
|
||||
|
||||
Assert.Equal("Organization invalid.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task AcceptOrgUserByOrgSsoIdAsync_UserNotInOrg_ThrowsBadRequest(SutProvider<AcceptOrgUserCommand> sutProvider,
|
||||
Organization org, User user)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(org.Identifier)
|
||||
.Returns(org);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(org.Id, user.Id)
|
||||
.Returns((OrganizationUser)null);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => 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<AcceptOrgUserCommand> sutProvider,
|
||||
User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails)
|
||||
{
|
||||
// Arrange
|
||||
SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(org.Id)
|
||||
.Returns(org);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.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<AcceptOrgUserCommand> sutProvider,
|
||||
Guid orgId, User user)
|
||||
{
|
||||
// Arrange
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(orgId)
|
||||
.Returns((Organization)null);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.AcceptOrgUserByOrgIdAsync(orgId, user, _userService));
|
||||
|
||||
Assert.Equal("Organization invalid.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task AcceptOrgUserByOrgId_UserNotInOrg_ThrowsBadRequest(SutProvider<AcceptOrgUserCommand> sutProvider,
|
||||
Organization org, User user)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(org.Id)
|
||||
.Returns(org);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(org.Id, user.Id)
|
||||
.Returns((OrganizationUser)null);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.AcceptOrgUserByOrgIdAsync(org.Id, user, _userService));
|
||||
|
||||
Assert.Equal("User not found within organization.", exception.Message);
|
||||
}
|
||||
|
||||
// Private helpers -------------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that the given org user is in the expected state after a successful AcceptOrgUserAsync call.
|
||||
/// For use in happy path tests.
|
||||
/// </summary>
|
||||
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<AcceptOrgUserCommand> sutProvider, User user, OrganizationUser orgUser)
|
||||
{
|
||||
sutProvider.GetDependency<IGlobalSettings>().OrganizationInviteExpirationHours.Returns(24);
|
||||
user.EmailVerified = false;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByIdAsync(orgUser.Id)
|
||||
.Returns(Task.FromResult(orgUser));
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetCountByOrganizationAsync(orgUser.OrganizationId, user.Email, true)
|
||||
.Returns(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void SetupCommonAcceptOrgUserMocks(SutProvider<AcceptOrgUserCommand> 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<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(user.Id)
|
||||
.Returns(
|
||||
Task.FromResult<ICollection<OrganizationUser>>(new List<OrganizationUser>())
|
||||
);
|
||||
|
||||
// Org they are trying to join does not have single org policy
|
||||
sutProvider.GetDependency<IPolicyService>()
|
||||
.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg, OrganizationUserStatusType.Invited)
|
||||
.Returns(
|
||||
Task.FromResult<ICollection<OrganizationUserPolicyDetails>>(
|
||||
new List<OrganizationUserPolicyDetails>()
|
||||
)
|
||||
);
|
||||
|
||||
// User is not part of any organization that applies the single org policy
|
||||
sutProvider.GetDependency<IPolicyService>()
|
||||
.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<IPolicyService>().GetPoliciesApplicableToUserAsync(user.Id,
|
||||
PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited)
|
||||
.Returns(Task.FromResult<ICollection<OrganizationUserPolicyDetails>>(
|
||||
new List<OrganizationUserPolicyDetails>()));
|
||||
|
||||
// Provide at least 1 admin to test email functionality
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByMinimumRoleAsync(orgUser.OrganizationId, OrganizationUserType.Admin)
|
||||
.Returns(Task.FromResult<IEnumerable<OrganizationUserUserDetails>>(
|
||||
new List<OrganizationUserUserDetails>() { adminUserDetails }
|
||||
));
|
||||
|
||||
// Return org
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(org.Id)
|
||||
.Returns(Task.FromResult(org));
|
||||
}
|
||||
|
||||
|
||||
private string CreateOldToken(SutProvider<AcceptOrgUserCommand> sutProvider,
|
||||
OrganizationUser organizationUser)
|
||||
{
|
||||
var dataProtector = sutProvider.GetDependency<IDataProtectionProvider>()
|
||||
.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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
@ -24,13 +25,14 @@ using Bit.Core.Test.AutoFixture;
|
||||
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;
|
||||
@ -43,10 +45,16 @@ namespace Bit.Core.Test.Services;
|
||||
[SutProviderCustomize]
|
||||
public class OrganizationServiceTests
|
||||
{
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>();
|
||||
|
||||
[Theory, PaidOrganizationCustomize, BitAutoData]
|
||||
public async Task OrgImportCreateNewUsers(SutProvider<OrganizationService> sutProvider, Guid userId,
|
||||
Organization org, List<OrganizationUserUserDetails> existingUsers, List<ImportedOrganizationUser> 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
|
||||
@ -67,6 +75,16 @@ public class OrganizationServiceTests
|
||||
.Returns(existingUsers.Select(u => new OrganizationUser { Status = OrganizationUserStatusType.Confirmed, Type = OrganizationUserType.Owner, Id = u.Id }).ToList());
|
||||
sutProvider.GetDependency<ICurrentContext>().ManageUsers(org.Id).Returns(true);
|
||||
|
||||
// Mock tokenable factory to return a token that expires in 5 days
|
||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||
.CreateToken(Arg.Any<OrganizationUser>())
|
||||
.Returns(
|
||||
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
}
|
||||
);
|
||||
|
||||
await sutProvider.Sut.ImportAsync(org.Id, userId, null, newUsers, null, false);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs()
|
||||
@ -98,6 +116,10 @@ public class OrganizationServiceTests
|
||||
Guid userId, Organization org, List<OrganizationUserUserDetails> existingUsers,
|
||||
List<ImportedOrganizationUser> 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();
|
||||
@ -121,6 +143,16 @@ public class OrganizationServiceTests
|
||||
var currentContext = sutProvider.GetDependency<ICurrentContext>();
|
||||
currentContext.ManageUsers(org.Id).Returns(true);
|
||||
|
||||
// Mock tokenable factory to return a token that expires in 5 days
|
||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||
.CreateToken(Arg.Any<OrganizationUser>())
|
||||
.Returns(
|
||||
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
}
|
||||
);
|
||||
|
||||
await sutProvider.Sut.ImportAsync(org.Id, userId, null, newUsers, null, false);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs()
|
||||
@ -350,6 +382,10 @@ public class OrganizationServiceTests
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
||||
OrganizationUserInvite invite, SutProvider<OrganizationService> 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<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
@ -359,6 +395,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<IOrgUserInviteTokenableFactory>()
|
||||
.CreateToken(Arg.Any<OrganizationUser>())
|
||||
.Returns(
|
||||
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
}
|
||||
);
|
||||
|
||||
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) });
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
||||
@ -587,6 +633,10 @@ public class OrganizationServiceTests
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
||||
SutProvider<OrganizationService> 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
|
||||
{
|
||||
@ -622,6 +672,16 @@ public class OrganizationServiceTests
|
||||
}
|
||||
});
|
||||
|
||||
// Mock tokenable factory to return a token that expires in 5 days
|
||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||
.CreateToken(Arg.Any<OrganizationUser>())
|
||||
.Returns(
|
||||
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
}
|
||||
);
|
||||
|
||||
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, invites);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
||||
@ -641,6 +701,10 @@ public class OrganizationServiceTests
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
||||
SutProvider<OrganizationService> 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
|
||||
{
|
||||
@ -656,6 +720,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<IOrgUserInviteTokenableFactory>()
|
||||
.CreateToken(Arg.Any<OrganizationUser>())
|
||||
.Returns(
|
||||
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
}
|
||||
);
|
||||
|
||||
await sutProvider.Sut.InviteUsersAsync(organization.Id, eventSystemUser, invites);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
||||
@ -1949,42 +2023,6 @@ public class OrganizationServiceTests
|
||||
sutProvider.Sut.ValidateSecretsManagerPlan(plan, signup);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[EphemeralDataProtectionAutoData]
|
||||
public async Task AcceptUserAsync_Success([OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser organizationUser,
|
||||
User user, SutProvider<OrganizationService> sutProvider)
|
||||
{
|
||||
var token = SetupAcceptUserAsyncTest(sutProvider, user, organizationUser);
|
||||
var userService = Substitute.For<IUserService>();
|
||||
|
||||
await sutProvider.Sut.AcceptUserAsync(organizationUser.Id, user, token, userService);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).ReplaceAsync(
|
||||
Arg.Is<OrganizationUser>(ou => ou.Id == organizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted));
|
||||
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync(
|
||||
Arg.Is<User>(u => u.Id == user.Id && u.Email == user.Email && user.EmailVerified == true));
|
||||
}
|
||||
|
||||
private string SetupAcceptUserAsyncTest(SutProvider<OrganizationService> sutProvider, User user,
|
||||
OrganizationUser organizationUser)
|
||||
{
|
||||
user.Email = organizationUser.Email;
|
||||
user.EmailVerified = false;
|
||||
|
||||
var dataProtector = sutProvider.GetDependency<IDataProtectionProvider>()
|
||||
.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<IGlobalSettings>().OrganizationInviteExpirationHours.Returns(24);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(organizationUser.Id)
|
||||
.Returns(organizationUser);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[OrganizationInviteCustomize(
|
||||
InviteeUserType = OrganizationUserType.Owner,
|
||||
|
@ -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<IFido2>(),
|
||||
sutProvider.GetDependency<ICurrentContext>(),
|
||||
sutProvider.GetDependency<IGlobalSettings>(),
|
||||
sutProvider.GetDependency<IOrganizationService>(),
|
||||
sutProvider.GetDependency<IAcceptOrgUserCommand>(),
|
||||
sutProvider.GetDependency<IProviderUserRepository>(),
|
||||
sutProvider.GetDependency<IStripeSyncService>(),
|
||||
new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>(),
|
||||
sutProvider.GetDependency<IWebAuthnCredentialRepository>(),
|
||||
sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnLoginTokenable>>()
|
||||
);
|
||||
|
Reference in New Issue
Block a user