Browse Source
* Move account recovery logic to command (temporarily duplicated behind feature flag) * Move permission checks to authorization handler * Prevent user from recovering provider member account unless they are also provider memberpull/6429/merge
16 changed files with 1261 additions and 19 deletions
@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
|
||||
using System.Security.Claims; |
||||
using Bit.Core.AdminConsole.Repositories; |
||||
using Bit.Core.Context; |
||||
using Bit.Core.Entities; |
||||
using Bit.Core.Enums; |
||||
using Microsoft.AspNetCore.Authorization; |
||||
|
||||
namespace Bit.Api.AdminConsole.Authorization; |
||||
|
||||
/// <summary> |
||||
/// An authorization requirement for recovering an organization member's account. |
||||
/// </summary> |
||||
/// <remarks> |
||||
/// Note: this is different to simply being able to manage account recovery. The user must be recovering |
||||
/// a member who has equal or lesser permissions than them. |
||||
/// </remarks> |
||||
public class RecoverAccountAuthorizationRequirement : IAuthorizationRequirement; |
||||
|
||||
/// <summary> |
||||
/// Authorizes members and providers to recover a target OrganizationUser's account. |
||||
/// </summary> |
||||
/// <remarks> |
||||
/// This prevents privilege escalation by ensuring that a user cannot recover the account of |
||||
/// another user with a higher role or with provider membership. |
||||
/// </remarks> |
||||
public class RecoverAccountAuthorizationHandler( |
||||
IOrganizationContext organizationContext, |
||||
ICurrentContext currentContext, |
||||
IProviderUserRepository providerUserRepository) |
||||
: AuthorizationHandler<RecoverAccountAuthorizationRequirement, OrganizationUser> |
||||
{ |
||||
public const string FailureReason = "You are not permitted to recover this user's account."; |
||||
public const string ProviderFailureReason = "You are not permitted to recover a Provider member's account."; |
||||
|
||||
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, |
||||
RecoverAccountAuthorizationRequirement requirement, |
||||
OrganizationUser targetOrganizationUser) |
||||
{ |
||||
// Step 1: check that the User has permissions with respect to the organization. |
||||
// This may come from their role in the organization or their provider relationship. |
||||
var canRecoverOrganizationMember = |
||||
AuthorizeMember(context.User, targetOrganizationUser) || |
||||
await AuthorizeProviderAsync(context.User, targetOrganizationUser); |
||||
|
||||
if (!canRecoverOrganizationMember) |
||||
{ |
||||
context.Fail(new AuthorizationFailureReason(this, FailureReason)); |
||||
return; |
||||
} |
||||
|
||||
// Step 2: check that the User has permissions with respect to any provider the target user is a member of. |
||||
// This prevents an organization admin performing privilege escalation into an unrelated provider. |
||||
var canRecoverProviderMember = await CanRecoverProviderAsync(targetOrganizationUser); |
||||
if (!canRecoverProviderMember) |
||||
{ |
||||
context.Fail(new AuthorizationFailureReason(this, ProviderFailureReason)); |
||||
return; |
||||
} |
||||
|
||||
context.Succeed(requirement); |
||||
} |
||||
|
||||
private async Task<bool> AuthorizeProviderAsync(ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser) |
||||
{ |
||||
return await organizationContext.IsProviderUserForOrganization(currentUser, targetOrganizationUser.OrganizationId); |
||||
} |
||||
|
||||
private bool AuthorizeMember(ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser) |
||||
{ |
||||
var currentContextOrganization = organizationContext.GetOrganizationClaims(currentUser, targetOrganizationUser.OrganizationId); |
||||
if (currentContextOrganization == null) |
||||
{ |
||||
return false; |
||||
} |
||||
|
||||
// Current user must have equal or greater permissions than the user account being recovered |
||||
var authorized = targetOrganizationUser.Type switch |
||||
{ |
||||
OrganizationUserType.Owner => currentContextOrganization.Type is OrganizationUserType.Owner, |
||||
OrganizationUserType.Admin => currentContextOrganization.Type is OrganizationUserType.Owner or OrganizationUserType.Admin, |
||||
_ => currentContextOrganization is |
||||
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } |
||||
or { Type: OrganizationUserType.Custom, Permissions.ManageResetPassword: true } |
||||
}; |
||||
|
||||
return authorized; |
||||
} |
||||
|
||||
private async Task<bool> CanRecoverProviderAsync(OrganizationUser targetOrganizationUser) |
||||
{ |
||||
if (!targetOrganizationUser.UserId.HasValue) |
||||
{ |
||||
// If an OrganizationUser is not linked to a User then it can't be linked to a Provider either. |
||||
// This is invalid but does not pose a privilege escalation risk. Return early and let the command |
||||
// handle the invalid input. |
||||
return true; |
||||
} |
||||
|
||||
var targetUserProviderUsers = |
||||
await providerUserRepository.GetManyByUserAsync(targetOrganizationUser.UserId.Value); |
||||
|
||||
// If the target user belongs to any provider that the current user is not a member of, |
||||
// deny the action to prevent privilege escalation from organization to provider. |
||||
// Note: we do not expect that a user is a member of more than 1 provider, but there is also no guarantee |
||||
// against it; this returns a sequence, so we handle the possibility. |
||||
var authorized = targetUserProviderUsers.All(providerUser => currentContext.ProviderUser(providerUser.ProviderId)); |
||||
return authorized; |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
|
||||
using Bit.Core.AdminConsole.Enums; |
||||
using Bit.Core.AdminConsole.Repositories; |
||||
using Bit.Core.Entities; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Exceptions; |
||||
using Bit.Core.Platform.Push; |
||||
using Bit.Core.Repositories; |
||||
using Bit.Core.Services; |
||||
using Microsoft.AspNetCore.Identity; |
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; |
||||
|
||||
public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepository, |
||||
IPolicyRepository policyRepository, |
||||
IUserRepository userRepository, |
||||
IMailService mailService, |
||||
IEventService eventService, |
||||
IPushNotificationService pushNotificationService, |
||||
IUserService userService, |
||||
TimeProvider timeProvider) : IAdminRecoverAccountCommand |
||||
{ |
||||
public async Task<IdentityResult> RecoverAccountAsync(Guid orgId, |
||||
OrganizationUser organizationUser, string newMasterPassword, string key) |
||||
{ |
||||
// Org must be able to use reset password |
||||
var org = await organizationRepository.GetByIdAsync(orgId); |
||||
if (org == null || !org.UseResetPassword) |
||||
{ |
||||
throw new BadRequestException("Organization does not allow password reset."); |
||||
} |
||||
|
||||
// Enterprise policy must be enabled |
||||
var resetPasswordPolicy = |
||||
await policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); |
||||
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled) |
||||
{ |
||||
throw new BadRequestException("Organization does not have the password reset policy enabled."); |
||||
} |
||||
|
||||
// Org User must be confirmed and have a ResetPasswordKey |
||||
if (organizationUser == null || |
||||
organizationUser.Status != OrganizationUserStatusType.Confirmed || |
||||
organizationUser.OrganizationId != orgId || |
||||
string.IsNullOrEmpty(organizationUser.ResetPasswordKey) || |
||||
!organizationUser.UserId.HasValue) |
||||
{ |
||||
throw new BadRequestException("Organization User not valid"); |
||||
} |
||||
|
||||
var user = await userService.GetUserByIdAsync(organizationUser.UserId.Value); |
||||
if (user == null) |
||||
{ |
||||
throw new NotFoundException(); |
||||
} |
||||
|
||||
if (user.UsesKeyConnector) |
||||
{ |
||||
throw new BadRequestException("Cannot reset password of a user with Key Connector."); |
||||
} |
||||
|
||||
var result = await userService.UpdatePasswordHash(user, newMasterPassword); |
||||
if (!result.Succeeded) |
||||
{ |
||||
return result; |
||||
} |
||||
|
||||
user.RevisionDate = user.AccountRevisionDate = timeProvider.GetUtcNow().UtcDateTime; |
||||
user.LastPasswordChangeDate = user.RevisionDate; |
||||
user.ForcePasswordReset = true; |
||||
user.Key = key; |
||||
|
||||
await userRepository.ReplaceAsync(user); |
||||
await mailService.SendAdminResetPasswordEmailAsync(user.Email, user.Name, org.DisplayName()); |
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_AdminResetPassword); |
||||
await pushNotificationService.PushLogOutAsync(user.Id); |
||||
|
||||
return IdentityResult.Success; |
||||
} |
||||
} |
||||
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
using Bit.Core.Entities; |
||||
using Bit.Core.Exceptions; |
||||
using Microsoft.AspNetCore.Identity; |
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; |
||||
|
||||
/// <summary> |
||||
/// A command used to recover an organization user's account by an organization admin. |
||||
/// </summary> |
||||
public interface IAdminRecoverAccountCommand |
||||
{ |
||||
/// <summary> |
||||
/// Recovers an organization user's account by resetting their master password. |
||||
/// </summary> |
||||
/// <param name="orgId">The organization the user belongs to.</param> |
||||
/// <param name="organizationUser">The organization user being recovered.</param> |
||||
/// <param name="newMasterPassword">The user's new master password hash.</param> |
||||
/// <param name="key">The user's new master-password-sealed user key.</param> |
||||
/// <returns>An IdentityResult indicating success or failure.</returns> |
||||
/// <exception cref="BadRequestException">When organization settings, policy, or user state is invalid.</exception> |
||||
/// <exception cref="NotFoundException">When the user does not exist.</exception> |
||||
Task<IdentityResult> RecoverAccountAsync(Guid orgId, OrganizationUser organizationUser, |
||||
string newMasterPassword, string key); |
||||
} |
||||
@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
|
||||
using System.Net; |
||||
using Bit.Api.AdminConsole.Authorization; |
||||
using Bit.Api.IntegrationTest.Factories; |
||||
using Bit.Api.IntegrationTest.Helpers; |
||||
using Bit.Api.Models.Request.Organizations; |
||||
using Bit.Core; |
||||
using Bit.Core.AdminConsole.Entities; |
||||
using Bit.Core.AdminConsole.Entities.Provider; |
||||
using Bit.Core.AdminConsole.Enums; |
||||
using Bit.Core.AdminConsole.Enums.Provider; |
||||
using Bit.Core.AdminConsole.Repositories; |
||||
using Bit.Core.Billing.Enums; |
||||
using Bit.Core.Entities; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Models.Api; |
||||
using Bit.Core.Repositories; |
||||
using Bit.Core.Services; |
||||
using NSubstitute; |
||||
using Xunit; |
||||
|
||||
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; |
||||
|
||||
public class OrganizationUsersControllerPutResetPasswordTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime |
||||
{ |
||||
private readonly HttpClient _client; |
||||
private readonly ApiApplicationFactory _factory; |
||||
private readonly LoginHelper _loginHelper; |
||||
|
||||
private Organization _organization = null!; |
||||
private string _ownerEmail = null!; |
||||
|
||||
public OrganizationUsersControllerPutResetPasswordTests(ApiApplicationFactory apiFactory) |
||||
{ |
||||
_factory = apiFactory; |
||||
_factory.SubstituteService<IFeatureService>(featureService => |
||||
{ |
||||
featureService |
||||
.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand) |
||||
.Returns(true); |
||||
}); |
||||
_client = _factory.CreateClient(); |
||||
_loginHelper = new LoginHelper(_factory, _client); |
||||
} |
||||
|
||||
public async Task InitializeAsync() |
||||
{ |
||||
_ownerEmail = $"reset-password-test-{Guid.NewGuid()}@example.com"; |
||||
await _factory.LoginWithNewAccount(_ownerEmail); |
||||
|
||||
(_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually2023, |
||||
ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); |
||||
|
||||
// Enable reset password and policies for the organization |
||||
var organizationRepository = _factory.GetService<IOrganizationRepository>(); |
||||
_organization.UseResetPassword = true; |
||||
_organization.UsePolicies = true; |
||||
await organizationRepository.ReplaceAsync(_organization); |
||||
|
||||
// Enable the ResetPassword policy |
||||
var policyRepository = _factory.GetService<IPolicyRepository>(); |
||||
await policyRepository.CreateAsync(new Policy |
||||
{ |
||||
OrganizationId = _organization.Id, |
||||
Type = PolicyType.ResetPassword, |
||||
Enabled = true, |
||||
Data = "{}" |
||||
}); |
||||
} |
||||
|
||||
public Task DisposeAsync() |
||||
{ |
||||
_client.Dispose(); |
||||
return Task.CompletedTask; |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Helper method to set the ResetPasswordKey on an organization user, which is required for account recovery |
||||
/// </summary> |
||||
private async Task SetResetPasswordKeyAsync(OrganizationUser orgUser) |
||||
{ |
||||
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>(); |
||||
orgUser.ResetPasswordKey = "encrypted-reset-password-key"; |
||||
await organizationUserRepository.ReplaceAsync(orgUser); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task PutResetPassword_AsHigherRole_CanRecoverLowerRole() |
||||
{ |
||||
// Arrange |
||||
var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, |
||||
_organization.Id, OrganizationUserType.Owner); |
||||
await _loginHelper.LoginAsync(ownerEmail); |
||||
|
||||
var (_, targetOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( |
||||
_factory, _organization.Id, OrganizationUserType.User); |
||||
await SetResetPasswordKeyAsync(targetOrgUser); |
||||
|
||||
var resetPasswordRequest = new OrganizationUserResetPasswordRequestModel |
||||
{ |
||||
NewMasterPasswordHash = "new-master-password-hash", |
||||
Key = "encrypted-recovery-key" |
||||
}; |
||||
|
||||
// Act |
||||
var response = await _client.PutAsJsonAsync( |
||||
$"organizations/{_organization.Id}/users/{targetOrgUser.Id}/reset-password", |
||||
resetPasswordRequest); |
||||
|
||||
// Assert |
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task PutResetPassword_AsLowerRole_CannotRecoverHigherRole() |
||||
{ |
||||
// Arrange |
||||
var (adminEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, |
||||
_organization.Id, OrganizationUserType.Admin); |
||||
await _loginHelper.LoginAsync(adminEmail); |
||||
|
||||
var (_, targetOwnerOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( |
||||
_factory, _organization.Id, OrganizationUserType.Owner); |
||||
await SetResetPasswordKeyAsync(targetOwnerOrgUser); |
||||
|
||||
var resetPasswordRequest = new OrganizationUserResetPasswordRequestModel |
||||
{ |
||||
NewMasterPasswordHash = "new-master-password-hash", |
||||
Key = "encrypted-recovery-key" |
||||
}; |
||||
|
||||
// Act |
||||
var response = await _client.PutAsJsonAsync( |
||||
$"organizations/{_organization.Id}/users/{targetOwnerOrgUser.Id}/reset-password", |
||||
resetPasswordRequest); |
||||
|
||||
// Assert |
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); |
||||
var model = await response.Content.ReadFromJsonAsync<ErrorResponseModel>(); |
||||
Assert.Contains(RecoverAccountAuthorizationHandler.FailureReason, model.Message); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task PutResetPassword_CannotRecoverProviderAccount() |
||||
{ |
||||
// Arrange - Create owner who will try to recover the provider account |
||||
var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, |
||||
_organization.Id, OrganizationUserType.Owner); |
||||
await _loginHelper.LoginAsync(ownerEmail); |
||||
|
||||
// Create a user who is also a provider user |
||||
var (targetUserEmail, targetOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( |
||||
_factory, _organization.Id, OrganizationUserType.User); |
||||
await SetResetPasswordKeyAsync(targetOrgUser); |
||||
|
||||
// Add the target user as a provider user to a different provider |
||||
var providerRepository = _factory.GetService<IProviderRepository>(); |
||||
var providerUserRepository = _factory.GetService<IProviderUserRepository>(); |
||||
var userRepository = _factory.GetService<IUserRepository>(); |
||||
|
||||
var provider = await providerRepository.CreateAsync(new Provider |
||||
{ |
||||
Name = "Test Provider", |
||||
BusinessName = "Test Provider Business", |
||||
BillingEmail = "provider@example.com", |
||||
Type = ProviderType.Msp, |
||||
Status = ProviderStatusType.Created, |
||||
Enabled = true |
||||
}); |
||||
|
||||
var targetUser = await userRepository.GetByEmailAsync(targetUserEmail); |
||||
Assert.NotNull(targetUser); |
||||
|
||||
await providerUserRepository.CreateAsync(new ProviderUser |
||||
{ |
||||
ProviderId = provider.Id, |
||||
UserId = targetUser.Id, |
||||
Status = ProviderUserStatusType.Confirmed, |
||||
Type = ProviderUserType.ProviderAdmin |
||||
}); |
||||
|
||||
var resetPasswordRequest = new OrganizationUserResetPasswordRequestModel |
||||
{ |
||||
NewMasterPasswordHash = "new-master-password-hash", |
||||
Key = "encrypted-recovery-key" |
||||
}; |
||||
|
||||
// Act |
||||
var response = await _client.PutAsJsonAsync( |
||||
$"organizations/{_organization.Id}/users/{targetOrgUser.Id}/reset-password", |
||||
resetPasswordRequest); |
||||
|
||||
// Assert |
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); |
||||
var model = await response.Content.ReadFromJsonAsync<ErrorResponseModel>(); |
||||
Assert.Equal(RecoverAccountAuthorizationHandler.ProviderFailureReason, model.Message); |
||||
} |
||||
} |
||||
@ -0,0 +1,296 @@
@@ -0,0 +1,296 @@
|
||||
using System.Security.Claims; |
||||
using Bit.Api.AdminConsole.Authorization; |
||||
using Bit.Core.AdminConsole.Entities.Provider; |
||||
using Bit.Core.AdminConsole.Repositories; |
||||
using Bit.Core.Context; |
||||
using Bit.Core.Entities; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Models.Data; |
||||
using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; |
||||
using Bit.Test.Common.AutoFixture; |
||||
using Bit.Test.Common.AutoFixture.Attributes; |
||||
using Microsoft.AspNetCore.Authorization; |
||||
using NSubstitute; |
||||
using Xunit; |
||||
|
||||
namespace Bit.Api.Test.AdminConsole.Authorization; |
||||
|
||||
[SutProviderCustomize] |
||||
public class RecoverAccountAuthorizationHandlerTests |
||||
{ |
||||
[Theory, BitAutoData] |
||||
public async Task HandleRequirementAsync_CurrentUserIsProvider_TargetUserNotProvider_Authorized( |
||||
SutProvider<RecoverAccountAuthorizationHandler> sutProvider, |
||||
[OrganizationUser] OrganizationUser targetOrganizationUser, |
||||
ClaimsPrincipal claimsPrincipal) |
||||
{ |
||||
// Arrange |
||||
var context = new AuthorizationHandlerContext( |
||||
[new RecoverAccountAuthorizationRequirement()], |
||||
claimsPrincipal, |
||||
targetOrganizationUser); |
||||
|
||||
MockOrganizationClaims(sutProvider, claimsPrincipal, targetOrganizationUser, null); |
||||
MockCurrentUserIsProvider(sutProvider, claimsPrincipal, targetOrganizationUser); |
||||
|
||||
// Act |
||||
await sutProvider.Sut.HandleAsync(context); |
||||
|
||||
// Assert |
||||
Assert.True(context.HasSucceeded); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleRequirementAsync_CurrentUserIsNotMemberOrProvider_NotAuthorized( |
||||
SutProvider<RecoverAccountAuthorizationHandler> sutProvider, |
||||
[OrganizationUser] OrganizationUser targetOrganizationUser, |
||||
ClaimsPrincipal claimsPrincipal) |
||||
{ |
||||
// Arrange |
||||
var context = new AuthorizationHandlerContext( |
||||
[new RecoverAccountAuthorizationRequirement()], |
||||
claimsPrincipal, |
||||
targetOrganizationUser); |
||||
|
||||
MockOrganizationClaims(sutProvider, claimsPrincipal, targetOrganizationUser, null); |
||||
|
||||
// Act |
||||
await sutProvider.Sut.HandleAsync(context); |
||||
|
||||
// Assert |
||||
AssertFailed(context, RecoverAccountAuthorizationHandler.FailureReason); |
||||
} |
||||
|
||||
// Pairing of CurrentContextOrganization (current user permissions) and target user role |
||||
// Read this as: a ___ can recover the account for a ___ |
||||
public static IEnumerable<object[]> AuthorizedRoleCombinations => new object[][] |
||||
{ |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Owner }, OrganizationUserType.Owner], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Owner }, OrganizationUserType.Admin], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Owner }, OrganizationUserType.Custom], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Owner }, OrganizationUserType.User], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Admin }, OrganizationUserType.Admin], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Admin }, OrganizationUserType.Custom], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Admin }, OrganizationUserType.User], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Custom, Permissions = new Permissions { ManageResetPassword = true}}, OrganizationUserType.Custom], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Custom, Permissions = new Permissions { ManageResetPassword = true}}, OrganizationUserType.User], |
||||
}; |
||||
|
||||
[Theory, BitMemberAutoData(nameof(AuthorizedRoleCombinations))] |
||||
public async Task AuthorizeMemberAsync_RecoverEqualOrLesserRoles_TargetUserNotProvider_Authorized( |
||||
CurrentContextOrganization currentContextOrganization, |
||||
OrganizationUserType targetOrganizationUserType, |
||||
SutProvider<RecoverAccountAuthorizationHandler> sutProvider, |
||||
[OrganizationUser] OrganizationUser targetOrganizationUser, |
||||
ClaimsPrincipal claimsPrincipal) |
||||
{ |
||||
// Arrange |
||||
targetOrganizationUser.Type = targetOrganizationUserType; |
||||
currentContextOrganization.Id = targetOrganizationUser.OrganizationId; |
||||
|
||||
var context = new AuthorizationHandlerContext( |
||||
[new RecoverAccountAuthorizationRequirement()], |
||||
claimsPrincipal, |
||||
targetOrganizationUser); |
||||
|
||||
MockOrganizationClaims(sutProvider, claimsPrincipal, targetOrganizationUser, currentContextOrganization); |
||||
|
||||
// Act |
||||
await sutProvider.Sut.HandleAsync(context); |
||||
|
||||
// Assert |
||||
Assert.True(context.HasSucceeded); |
||||
} |
||||
|
||||
// Pairing of CurrentContextOrganization (current user permissions) and target user role |
||||
// Read this as: a ___ cannot recover the account for a ___ |
||||
public static IEnumerable<object[]> UnauthorizedRoleCombinations => new object[][] |
||||
{ |
||||
// These roles should fail because you cannot recover a greater role |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Admin }, OrganizationUserType.Owner], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Custom, Permissions = new Permissions { ManageResetPassword = true}}, OrganizationUserType.Owner], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Custom, Permissions = new Permissions { ManageResetPassword = true} }, OrganizationUserType.Admin], |
||||
|
||||
// These roles are never authorized to recover any account |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.User }, OrganizationUserType.Owner], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.User }, OrganizationUserType.Admin], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.User }, OrganizationUserType.Custom], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.User }, OrganizationUserType.User], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Custom }, OrganizationUserType.Owner], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Custom }, OrganizationUserType.Admin], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Custom }, OrganizationUserType.Custom], |
||||
[new CurrentContextOrganization { Type = OrganizationUserType.Custom }, OrganizationUserType.User], |
||||
}; |
||||
|
||||
[Theory, BitMemberAutoData(nameof(UnauthorizedRoleCombinations))] |
||||
public async Task AuthorizeMemberAsync_InvalidRoles_TargetUserNotProvider_Unauthorized( |
||||
CurrentContextOrganization currentContextOrganization, |
||||
OrganizationUserType targetOrganizationUserType, |
||||
SutProvider<RecoverAccountAuthorizationHandler> sutProvider, |
||||
[OrganizationUser] OrganizationUser targetOrganizationUser, |
||||
ClaimsPrincipal claimsPrincipal) |
||||
{ |
||||
// Arrange |
||||
targetOrganizationUser.Type = targetOrganizationUserType; |
||||
currentContextOrganization.Id = targetOrganizationUser.OrganizationId; |
||||
|
||||
var context = new AuthorizationHandlerContext( |
||||
[new RecoverAccountAuthorizationRequirement()], |
||||
claimsPrincipal, |
||||
targetOrganizationUser); |
||||
|
||||
MockOrganizationClaims(sutProvider, claimsPrincipal, targetOrganizationUser, currentContextOrganization); |
||||
|
||||
// Act |
||||
await sutProvider.Sut.HandleAsync(context); |
||||
|
||||
// Assert |
||||
AssertFailed(context, RecoverAccountAuthorizationHandler.FailureReason); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleRequirementAsync_TargetUserIdIsNull_DoesNotBlock( |
||||
SutProvider<RecoverAccountAuthorizationHandler> sutProvider, |
||||
OrganizationUser targetOrganizationUser, |
||||
ClaimsPrincipal claimsPrincipal) |
||||
{ |
||||
// Arrange |
||||
targetOrganizationUser.UserId = null; |
||||
MockCurrentUserIsOwner(sutProvider, claimsPrincipal, targetOrganizationUser); |
||||
|
||||
var context = new AuthorizationHandlerContext( |
||||
[new RecoverAccountAuthorizationRequirement()], |
||||
claimsPrincipal, |
||||
targetOrganizationUser); |
||||
|
||||
// Act |
||||
await sutProvider.Sut.HandleAsync(context); |
||||
|
||||
// Assert |
||||
Assert.True(context.HasSucceeded); |
||||
// This should shortcut the provider escalation check |
||||
await sutProvider.GetDependency<IProviderUserRepository>().DidNotReceiveWithAnyArgs() |
||||
.GetManyByUserAsync(Arg.Any<Guid>()); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleRequirementAsync_CurrentUserIsMemberOfAllTargetUserProviders_DoesNotBlock( |
||||
SutProvider<RecoverAccountAuthorizationHandler> sutProvider, |
||||
[OrganizationUser] OrganizationUser targetOrganizationUser, |
||||
ClaimsPrincipal claimsPrincipal, |
||||
Guid providerId1, |
||||
Guid providerId2) |
||||
{ |
||||
// Arrange |
||||
var targetUserProviders = new List<ProviderUser> |
||||
{ |
||||
new() { ProviderId = providerId1, UserId = targetOrganizationUser.UserId }, |
||||
new() { ProviderId = providerId2, UserId = targetOrganizationUser.UserId } |
||||
}; |
||||
|
||||
var context = new AuthorizationHandlerContext( |
||||
[new RecoverAccountAuthorizationRequirement()], |
||||
claimsPrincipal, |
||||
targetOrganizationUser); |
||||
|
||||
MockCurrentUserIsProvider(sutProvider, claimsPrincipal, targetOrganizationUser); |
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>() |
||||
.GetManyByUserAsync(targetOrganizationUser.UserId!.Value) |
||||
.Returns(targetUserProviders); |
||||
|
||||
sutProvider.GetDependency<ICurrentContext>() |
||||
.ProviderUser(providerId1) |
||||
.Returns(true); |
||||
|
||||
sutProvider.GetDependency<ICurrentContext>() |
||||
.ProviderUser(providerId2) |
||||
.Returns(true); |
||||
|
||||
// Act |
||||
await sutProvider.Sut.HandleAsync(context); |
||||
|
||||
// Assert |
||||
Assert.True(context.HasSucceeded); |
||||
} |
||||
|
||||
[Theory, BitAutoData] |
||||
public async Task HandleRequirementAsync_CurrentUserMissingProviderMembership_Blocks( |
||||
SutProvider<RecoverAccountAuthorizationHandler> sutProvider, |
||||
[OrganizationUser] OrganizationUser targetOrganizationUser, |
||||
ClaimsPrincipal claimsPrincipal, |
||||
Guid providerId1, |
||||
Guid providerId2) |
||||
{ |
||||
// Arrange |
||||
var targetUserProviders = new List<ProviderUser> |
||||
{ |
||||
new() { ProviderId = providerId1, UserId = targetOrganizationUser.UserId }, |
||||
new() { ProviderId = providerId2, UserId = targetOrganizationUser.UserId } |
||||
}; |
||||
|
||||
var context = new AuthorizationHandlerContext( |
||||
[new RecoverAccountAuthorizationRequirement()], |
||||
claimsPrincipal, |
||||
targetOrganizationUser); |
||||
|
||||
MockCurrentUserIsOwner(sutProvider, claimsPrincipal, targetOrganizationUser); |
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>() |
||||
.GetManyByUserAsync(targetOrganizationUser.UserId!.Value) |
||||
.Returns(targetUserProviders); |
||||
|
||||
sutProvider.GetDependency<ICurrentContext>() |
||||
.ProviderUser(providerId1) |
||||
.Returns(true); |
||||
|
||||
// Not a member of this provider |
||||
sutProvider.GetDependency<ICurrentContext>() |
||||
.ProviderUser(providerId2) |
||||
.Returns(false); |
||||
|
||||
// Act |
||||
await sutProvider.Sut.HandleAsync(context); |
||||
|
||||
// Assert |
||||
AssertFailed(context, RecoverAccountAuthorizationHandler.ProviderFailureReason); |
||||
} |
||||
|
||||
private static void MockOrganizationClaims(SutProvider<RecoverAccountAuthorizationHandler> sutProvider, |
||||
ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser, |
||||
CurrentContextOrganization? currentContextOrganization) |
||||
{ |
||||
sutProvider.GetDependency<IOrganizationContext>() |
||||
.GetOrganizationClaims(currentUser, targetOrganizationUser.OrganizationId) |
||||
.Returns(currentContextOrganization); |
||||
} |
||||
|
||||
private static void MockCurrentUserIsProvider(SutProvider<RecoverAccountAuthorizationHandler> sutProvider, |
||||
ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser) |
||||
{ |
||||
sutProvider.GetDependency<IOrganizationContext>() |
||||
.IsProviderUserForOrganization(currentUser, targetOrganizationUser.OrganizationId) |
||||
.Returns(true); |
||||
} |
||||
|
||||
private static void MockCurrentUserIsOwner(SutProvider<RecoverAccountAuthorizationHandler> sutProvider, |
||||
ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser) |
||||
{ |
||||
var currentContextOrganization = new CurrentContextOrganization |
||||
{ |
||||
Id = targetOrganizationUser.OrganizationId, |
||||
Type = OrganizationUserType.Owner |
||||
}; |
||||
|
||||
sutProvider.GetDependency<IOrganizationContext>() |
||||
.GetOrganizationClaims(currentUser, targetOrganizationUser.OrganizationId) |
||||
.Returns(currentContextOrganization); |
||||
} |
||||
|
||||
private static void AssertFailed(AuthorizationHandlerContext context, string expectedMessage) |
||||
{ |
||||
Assert.True(context.HasFailed); |
||||
var failureReason = Assert.Single(context.FailureReasons); |
||||
Assert.Equal(expectedMessage, failureReason.Message); |
||||
} |
||||
} |
||||
@ -0,0 +1,296 @@
@@ -0,0 +1,296 @@
|
||||
using AutoFixture; |
||||
using Bit.Core.AdminConsole.Entities; |
||||
using Bit.Core.AdminConsole.Enums; |
||||
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; |
||||
using Bit.Core.AdminConsole.Repositories; |
||||
using Bit.Core.Entities; |
||||
using Bit.Core.Enums; |
||||
using Bit.Core.Exceptions; |
||||
using Bit.Core.Platform.Push; |
||||
using Bit.Core.Repositories; |
||||
using Bit.Core.Services; |
||||
using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; |
||||
using Bit.Test.Common.AutoFixture; |
||||
using Bit.Test.Common.AutoFixture.Attributes; |
||||
using Microsoft.AspNetCore.Identity; |
||||
using NSubstitute; |
||||
using Xunit; |
||||
|
||||
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.AccountRecovery; |
||||
|
||||
[SutProviderCustomize] |
||||
public class AdminRecoverAccountCommandTests |
||||
{ |
||||
[Theory] |
||||
[BitAutoData] |
||||
public async Task RecoverAccountAsync_Success( |
||||
string newMasterPassword, |
||||
string key, |
||||
Organization organization, |
||||
OrganizationUser organizationUser, |
||||
User user, |
||||
SutProvider<AdminRecoverAccountCommand> sutProvider) |
||||
{ |
||||
// Arrange |
||||
SetupValidOrganization(sutProvider, organization); |
||||
SetupValidPolicy(sutProvider, organization); |
||||
SetupValidOrganizationUser(organizationUser, organization.Id); |
||||
SetupValidUser(sutProvider, user, organizationUser); |
||||
SetupSuccessfulPasswordUpdate(sutProvider, user, newMasterPassword); |
||||
|
||||
// Act |
||||
var result = await sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key); |
||||
|
||||
// Assert |
||||
Assert.True(result.Succeeded); |
||||
await AssertSuccessAsync(sutProvider, user, key, organization, organizationUser); |
||||
} |
||||
|
||||
[Theory] |
||||
[BitAutoData] |
||||
public async Task RecoverAccountAsync_OrganizationDoesNotExist_ThrowsBadRequest( |
||||
[OrganizationUser] OrganizationUser organizationUser, |
||||
string newMasterPassword, |
||||
string key, |
||||
SutProvider<AdminRecoverAccountCommand> sutProvider) |
||||
{ |
||||
// Arrange |
||||
var orgId = Guid.NewGuid(); |
||||
sutProvider.GetDependency<IOrganizationRepository>() |
||||
.GetByIdAsync(orgId) |
||||
.Returns((Organization)null); |
||||
|
||||
// Act & Assert |
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => |
||||
sutProvider.Sut.RecoverAccountAsync(orgId, organizationUser, newMasterPassword, key)); |
||||
Assert.Equal("Organization does not allow password reset.", exception.Message); |
||||
} |
||||
|
||||
[Theory] |
||||
[BitAutoData] |
||||
public async Task RecoverAccountAsync_OrganizationDoesNotAllowResetPassword_ThrowsBadRequest( |
||||
string newMasterPassword, |
||||
string key, |
||||
Organization organization, |
||||
[OrganizationUser] OrganizationUser organizationUser, |
||||
SutProvider<AdminRecoverAccountCommand> sutProvider) |
||||
{ |
||||
// Arrange |
||||
organization.UseResetPassword = false; |
||||
sutProvider.GetDependency<IOrganizationRepository>() |
||||
.GetByIdAsync(organization.Id) |
||||
.Returns(organization); |
||||
|
||||
// Act & Assert |
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => |
||||
sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key)); |
||||
Assert.Equal("Organization does not allow password reset.", exception.Message); |
||||
} |
||||
|
||||
public static IEnumerable<object[]> InvalidPolicies => new object[][] |
||||
{ |
||||
[new Policy { Type = PolicyType.ResetPassword, Enabled = false }], [null] |
||||
}; |
||||
|
||||
[Theory] |
||||
[BitMemberAutoData(nameof(InvalidPolicies))] |
||||
public async Task RecoverAccountAsync_InvalidPolicy_ThrowsBadRequest( |
||||
Policy resetPasswordPolicy, |
||||
string newMasterPassword, |
||||
string key, |
||||
Organization organization, |
||||
SutProvider<AdminRecoverAccountCommand> sutProvider) |
||||
{ |
||||
// Arrange |
||||
SetupValidOrganization(sutProvider, organization); |
||||
sutProvider.GetDependency<IPolicyRepository>() |
||||
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword) |
||||
.Returns(resetPasswordPolicy); |
||||
|
||||
// Act & Assert |
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => |
||||
sutProvider.Sut.RecoverAccountAsync(organization.Id, new OrganizationUser { Id = Guid.NewGuid() }, |
||||
newMasterPassword, key)); |
||||
Assert.Equal("Organization does not have the password reset policy enabled.", exception.Message); |
||||
} |
||||
|
||||
public static IEnumerable<object[]> InvalidOrganizationUsers() |
||||
{ |
||||
// Make an organization so we can use its Id |
||||
var organization = new Fixture().Create<Organization>(); |
||||
|
||||
var nonConfirmed = new OrganizationUser |
||||
{ |
||||
Id = Guid.NewGuid(), |
||||
OrganizationId = organization.Id, |
||||
Status = OrganizationUserStatusType.Invited |
||||
}; |
||||
yield return [nonConfirmed, organization]; |
||||
|
||||
var wrongOrganization = new OrganizationUser |
||||
{ |
||||
Status = OrganizationUserStatusType.Confirmed, |
||||
OrganizationId = Guid.NewGuid(), // Different org |
||||
ResetPasswordKey = "test-key", |
||||
UserId = Guid.NewGuid(), |
||||
}; |
||||
yield return [wrongOrganization, organization]; |
||||
|
||||
var nullResetPasswordKey = new OrganizationUser |
||||
{ |
||||
Status = OrganizationUserStatusType.Confirmed, |
||||
OrganizationId = organization.Id, |
||||
ResetPasswordKey = null, |
||||
UserId = Guid.NewGuid(), |
||||
}; |
||||
yield return [nullResetPasswordKey, organization]; |
||||
|
||||
var emptyResetPasswordKey = new OrganizationUser |
||||
{ |
||||
Status = OrganizationUserStatusType.Confirmed, |
||||
OrganizationId = organization.Id, |
||||
ResetPasswordKey = "", |
||||
UserId = Guid.NewGuid(), |
||||
}; |
||||
yield return [emptyResetPasswordKey, organization]; |
||||
|
||||
var nullUserId = new OrganizationUser |
||||
{ |
||||
Status = OrganizationUserStatusType.Confirmed, |
||||
OrganizationId = organization.Id, |
||||
ResetPasswordKey = "test-key", |
||||
UserId = null, |
||||
}; |
||||
yield return [nullUserId, organization]; |
||||
} |
||||
|
||||
[Theory] |
||||
[BitMemberAutoData(nameof(InvalidOrganizationUsers))] |
||||
public async Task RecoverAccountAsync_OrganizationUserIsInvalid_ThrowsBadRequest( |
||||
OrganizationUser organizationUser, |
||||
Organization organization, |
||||
string newMasterPassword, |
||||
string key, |
||||
SutProvider<AdminRecoverAccountCommand> sutProvider) |
||||
{ |
||||
// Arrange |
||||
SetupValidOrganization(sutProvider, organization); |
||||
SetupValidPolicy(sutProvider, organization); |
||||
|
||||
// Act & Assert |
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => |
||||
sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key)); |
||||
Assert.Equal("Organization User not valid", exception.Message); |
||||
} |
||||
|
||||
[Theory] |
||||
[BitAutoData] |
||||
public async Task RecoverAccountAsync_UserDoesNotExist_ThrowsNotFoundException( |
||||
string newMasterPassword, |
||||
string key, |
||||
Organization organization, |
||||
OrganizationUser organizationUser, |
||||
SutProvider<AdminRecoverAccountCommand> sutProvider) |
||||
{ |
||||
// Arrange |
||||
SetupValidOrganization(sutProvider, organization); |
||||
SetupValidPolicy(sutProvider, organization); |
||||
SetupValidOrganizationUser(organizationUser, organization.Id); |
||||
sutProvider.GetDependency<IUserService>() |
||||
.GetUserByIdAsync(organizationUser.UserId!.Value) |
||||
.Returns((User)null); |
||||
|
||||
// Act & Assert |
||||
await Assert.ThrowsAsync<NotFoundException>(() => |
||||
sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key)); |
||||
} |
||||
|
||||
[Theory] |
||||
[BitAutoData] |
||||
public async Task RecoverAccountAsync_UserUsesKeyConnector_ThrowsBadRequest( |
||||
string newMasterPassword, |
||||
string key, |
||||
Organization organization, |
||||
OrganizationUser organizationUser, |
||||
User user, |
||||
SutProvider<AdminRecoverAccountCommand> sutProvider) |
||||
{ |
||||
// Arrange |
||||
SetupValidOrganization(sutProvider, organization); |
||||
SetupValidPolicy(sutProvider, organization); |
||||
SetupValidOrganizationUser(organizationUser, organization.Id); |
||||
user.UsesKeyConnector = true; |
||||
sutProvider.GetDependency<IUserService>() |
||||
.GetUserByIdAsync(organizationUser.UserId!.Value) |
||||
.Returns(user); |
||||
|
||||
// Act & Assert |
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => |
||||
sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key)); |
||||
Assert.Equal("Cannot reset password of a user with Key Connector.", exception.Message); |
||||
} |
||||
|
||||
private static void SetupValidOrganization(SutProvider<AdminRecoverAccountCommand> sutProvider, Organization organization) |
||||
{ |
||||
organization.UseResetPassword = true; |
||||
sutProvider.GetDependency<IOrganizationRepository>() |
||||
.GetByIdAsync(organization.Id) |
||||
.Returns(organization); |
||||
} |
||||
|
||||
private static void SetupValidPolicy(SutProvider<AdminRecoverAccountCommand> sutProvider, Organization organization) |
||||
{ |
||||
var policy = new Policy { Type = PolicyType.ResetPassword, Enabled = true }; |
||||
sutProvider.GetDependency<IPolicyRepository>() |
||||
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword) |
||||
.Returns(policy); |
||||
} |
||||
|
||||
private static void SetupValidOrganizationUser(OrganizationUser organizationUser, Guid orgId) |
||||
{ |
||||
organizationUser.Status = OrganizationUserStatusType.Confirmed; |
||||
organizationUser.OrganizationId = orgId; |
||||
organizationUser.ResetPasswordKey = "test-key"; |
||||
organizationUser.Type = OrganizationUserType.User; |
||||
} |
||||
|
||||
private static void SetupValidUser(SutProvider<AdminRecoverAccountCommand> sutProvider, User user, OrganizationUser organizationUser) |
||||
{ |
||||
user.Id = organizationUser.UserId!.Value; |
||||
user.UsesKeyConnector = false; |
||||
sutProvider.GetDependency<IUserService>() |
||||
.GetUserByIdAsync(user.Id) |
||||
.Returns(user); |
||||
} |
||||
|
||||
private static void SetupSuccessfulPasswordUpdate(SutProvider<AdminRecoverAccountCommand> sutProvider, User user, string newMasterPassword) |
||||
{ |
||||
sutProvider.GetDependency<IUserService>() |
||||
.UpdatePasswordHash(user, newMasterPassword) |
||||
.Returns(IdentityResult.Success); |
||||
} |
||||
|
||||
private static async Task AssertSuccessAsync(SutProvider<AdminRecoverAccountCommand> sutProvider, User user, string key, |
||||
Organization organization, OrganizationUser organizationUser) |
||||
{ |
||||
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync( |
||||
Arg.Is<User>(u => |
||||
u.Id == user.Id && |
||||
u.Key == key && |
||||
u.ForcePasswordReset == true && |
||||
u.RevisionDate == u.AccountRevisionDate && |
||||
u.LastPasswordChangeDate == u.RevisionDate)); |
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1).SendAdminResetPasswordEmailAsync( |
||||
Arg.Is(user.Email), |
||||
Arg.Is(user.Name), |
||||
Arg.Is(organization.DisplayName())); |
||||
|
||||
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventAsync( |
||||
Arg.Is(organizationUser), |
||||
Arg.Is(EventType.OrganizationUser_AdminResetPassword)); |
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushLogOutAsync( |
||||
Arg.Is(user.Id)); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue