1
0
mirror of https://github.com/bitwarden/server synced 2025-12-15 15:53:59 +00:00

[PM-24192] Move account recovery logic to command (#6184)

* 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 member
This commit is contained in:
Thomas Rittson
2025-11-01 07:55:25 +10:00
committed by GitHub
parent 09564947e8
commit e11458196c
16 changed files with 1261 additions and 19 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -1,11 +1,14 @@
using System.Security.Claims;
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
@@ -16,6 +19,7 @@ using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Api;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations;
@@ -30,6 +34,7 @@ using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using NSubstitute;
using Xunit;
@@ -440,4 +445,153 @@ public class OrganizationUsersControllerTests
Assert.Equal("Master Password reset is required, but not provided.", exception.Message);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagDisabled_CallsLegacyPath(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().AdminResetPasswordAsync(Arg.Any<OrganizationUserType>(), orgId, orgUserId, model.NewMasterPasswordHash, model.Key)
.Returns(Microsoft.AspNetCore.Identity.IdentityResult.Success);
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<Ok>(result);
await sutProvider.GetDependency<IUserService>().Received(1)
.AdminResetPasswordAsync(OrganizationUserType.Owner, orgId, orgUserId, model.NewMasterPasswordHash, model.Key);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagDisabled_WhenOrgUserTypeIsNull_ReturnsNotFound(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(orgId).Returns(false);
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns(new List<CurrentContextOrganization>());
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<NotFound>(result);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagDisabled_WhenAdminResetPasswordFails_ReturnsBadRequest(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().AdminResetPasswordAsync(Arg.Any<OrganizationUserType>(), orgId, orgUserId, model.NewMasterPasswordHash, model.Key)
.Returns(Microsoft.AspNetCore.Identity.IdentityResult.Failed(new Microsoft.AspNetCore.Identity.IdentityError { Description = "Error 1" }));
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<BadRequest<ModelStateDictionary>>(result);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenOrganizationUserNotFound_ReturnsNotFound(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns((OrganizationUser)null);
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<NotFound>(result);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenOrganizationIdMismatch_ReturnsNotFound(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = Guid.NewGuid();
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<NotFound>(result);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenAuthorizationFails_ReturnsBadRequest(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = orgId;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(),
organizationUser,
Arg.Is<IEnumerable<IAuthorizationRequirement>>(x => x.SingleOrDefault() is RecoverAccountAuthorizationRequirement))
.Returns(AuthorizationResult.Failed());
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<BadRequest<ErrorResponseModel>>(result);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenRecoverAccountSucceeds_ReturnsOk(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = orgId;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(),
organizationUser,
Arg.Is<IEnumerable<IAuthorizationRequirement>>(x => x.SingleOrDefault() is RecoverAccountAuthorizationRequirement))
.Returns(AuthorizationResult.Success());
sutProvider.GetDependency<IAdminRecoverAccountCommand>()
.RecoverAccountAsync(orgId, organizationUser, model.NewMasterPasswordHash, model.Key)
.Returns(Microsoft.AspNetCore.Identity.IdentityResult.Success);
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<Ok>(result);
await sutProvider.GetDependency<IAdminRecoverAccountCommand>().Received(1)
.RecoverAccountAsync(orgId, organizationUser, model.NewMasterPasswordHash, model.Key);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenRecoverAccountFails_ReturnsBadRequest(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = orgId;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(),
organizationUser,
Arg.Is<IEnumerable<IAuthorizationRequirement>>(x => x.SingleOrDefault() is RecoverAccountAuthorizationRequirement))
.Returns(AuthorizationResult.Success());
sutProvider.GetDependency<IAdminRecoverAccountCommand>()
.RecoverAccountAsync(orgId, organizationUser, model.NewMasterPasswordHash, model.Key)
.Returns(Microsoft.AspNetCore.Identity.IdentityResult.Failed(new Microsoft.AspNetCore.Identity.IdentityError { Description = "Error message" }));
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<BadRequest<ModelStateDictionary>>(result);
}
}

View File

@@ -1,4 +1,6 @@
using AutoFixture;
using System.Reflection;
using AutoFixture;
using AutoFixture.Xunit2;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
@@ -23,6 +25,7 @@ public class CurrentContextOrganizationCustomization : ICustomization
}
}
[AttributeUsage(AttributeTargets.Method)]
public class CurrentContextOrganizationCustomizeAttribute : BitCustomizeAttribute
{
public Guid Id { get; set; }
@@ -38,3 +41,19 @@ public class CurrentContextOrganizationCustomizeAttribute : BitCustomizeAttribut
AccessSecretsManager = AccessSecretsManager
};
}
public class CurrentContextOrganizationAttribute : CustomizeAttribute
{
public Guid Id { get; set; }
public OrganizationUserType Type { get; set; } = OrganizationUserType.User;
public Permissions Permissions { get; set; } = new();
public bool AccessSecretsManager { get; set; } = false;
public override ICustomization GetCustomization(ParameterInfo _) => new CurrentContextOrganizationCustomization
{
Id = Id,
Type = Type,
Permissions = Permissions,
AccessSecretsManager = AccessSecretsManager
};
}

View File

@@ -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));
}
}