1
0
mirror of https://github.com/bitwarden/server synced 2026-01-07 11:03:37 +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

@@ -13,9 +13,10 @@ public static class AuthorizationHandlerCollectionExtensions
services.TryAddEnumerable([
ServiceDescriptor.Scoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, GroupAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationRequirementHandler>(),
]);
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, GroupAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationRequirementHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, RecoverAccountAuthorizationHandler>(),
]);
}
}

View File

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

View File

@@ -1,4 +1,5 @@
// FIXME: Update this file to be null safe and then delete the line below
// NOTE: This file is partially migrated to nullable reference types. Remove inline #nullable directives when addressing the FIXME.
#nullable disable
using Bit.Api.AdminConsole.Authorization;
@@ -11,6 +12,7 @@ using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
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.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
@@ -70,6 +72,7 @@ public class OrganizationUsersController : Controller
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
public OrganizationUsersController(IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -97,7 +100,8 @@ public class OrganizationUsersController : Controller
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IInitPendingOrganizationCommand initPendingOrganizationCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IAdminRecoverAccountCommand adminRecoverAccountCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -126,6 +130,7 @@ public class OrganizationUsersController : Controller
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_initPendingOrganizationCommand = initPendingOrganizationCommand;
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
_adminRecoverAccountCommand = adminRecoverAccountCommand;
}
[HttpGet("{id}")]
@@ -474,21 +479,27 @@ public class OrganizationUsersController : Controller
[HttpPut("{id}/reset-password")]
[Authorize<ManageAccountRecoveryRequirement>]
public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
public async Task<IResult> PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
if (_featureService.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand))
{
// TODO: remove legacy implementation after feature flag is enabled.
return await PutResetPasswordNew(orgId, id, model);
}
// Get the users role, since provider users aren't a member of the organization we use the owner check
var orgUserType = await _currentContext.OrganizationOwner(orgId)
? OrganizationUserType.Owner
: _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type;
if (orgUserType == null)
{
throw new NotFoundException();
return TypedResults.NotFound();
}
var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded)
{
return;
return TypedResults.Ok();
}
foreach (var error in result.Errors)
@@ -497,9 +508,45 @@ public class OrganizationUsersController : Controller
}
await Task.Delay(2000);
throw new BadRequestException(ModelState);
return TypedResults.BadRequest(ModelState);
}
#nullable enable
// TODO: make sure the route and authorize attributes are maintained when the legacy implementation is removed.
private async Task<IResult> PutResetPasswordNew(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId)
{
return TypedResults.NotFound();
}
var authorizationResult = await _authorizationService.AuthorizeAsync(User, targetOrganizationUser, new RecoverAccountAuthorizationRequirement());
if (!authorizationResult.Succeeded)
{
// Return an informative error to show in the UI.
// The Authorize attribute already prevents enumeration by users outside the organization, so this can be specific.
var failureReason = authorizationResult.Failure?.FailureReasons.FirstOrDefault()?.Message ?? RecoverAccountAuthorizationHandler.FailureReason;
// This should be a 403 Forbidden, but that causes a logout on our client apps so we're using 400 Bad Request instead
return TypedResults.BadRequest(new ErrorResponseModel(failureReason));
}
var result = await _adminRecoverAccountCommand.RecoverAccountAsync(orgId, targetOrganizationUser, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded)
{
return TypedResults.Ok();
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
await Task.Delay(2000);
return TypedResults.BadRequest(ModelState);
}
#nullable disable
[HttpDelete("{id}")]
[Authorize<ManageUsersRequirement>]
public async Task Remove(Guid orgId, Guid id)

View File

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

View File

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

View File

@@ -142,6 +142,7 @@ public static class FeatureFlagKeys
public const string CreateDefaultLocation = "pm-19467-create-default-location";
public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users";
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string AccountRecoveryCommand = "pm-24192-account-recovery-command";
/* Auth Team */
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";

View File

@@ -1,6 +1,4 @@
#nullable enable
using System.Security.Claims;
using System.Security.Claims;
using Bit.Core.AdminConsole.Context;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Identity;
@@ -12,6 +10,14 @@ using Microsoft.AspNetCore.Http;
namespace Bit.Core.Context;
/// <summary>
/// Provides information about the current HTTP request and the currently authenticated user (if any).
/// This is often (but not exclusively) parsed from the JWT in the current request.
/// </summary>
/// <remarks>
/// This interface suffers from having too much responsibility; consider whether any new code can go in a more
/// specific class rather than adding it here.
/// </remarks>
public interface ICurrentContext
{
HttpContext HttpContext { get; set; }
@@ -59,8 +65,20 @@ public interface ICurrentContext
Task<bool> EditSubscription(Guid orgId);
Task<bool> EditPaymentMethods(Guid orgId);
Task<bool> ViewBillingHistory(Guid orgId);
/// <summary>
/// Returns true if the current user is a member of a provider that manages the specified organization.
/// This generally gives the user administrative privileges for the organization.
/// </summary>
/// <param name="orgId"></param>
/// <returns></returns>
Task<bool> ProviderUserForOrgAsync(Guid orgId);
/// <summary>
/// Returns true if the current user is a Provider Admin of the specified provider.
/// </summary>
bool ProviderProviderAdmin(Guid providerId);
/// <summary>
/// Returns true if the current user is a member of the specified provider (with any role).
/// </summary>
bool ProviderUser(Guid providerId);
bool ProviderManageUsers(Guid providerId);
bool ProviderAccessEventLogs(Guid providerId);

View File

@@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.OrganizationAuth;
using Bit.Core.AdminConsole.OrganizationAuth.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Import;
@@ -133,6 +134,7 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<IUpdateOrganizationUserCommand, UpdateOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>();
services.AddScoped<IConfirmOrganizationUserCommand, ConfirmOrganizationUserCommand>();
services.AddScoped<IAdminRecoverAccountCommand, AdminRecoverAccountCommand>();
services.AddScoped<IDeleteClaimedOrganizationUserAccountCommand, DeleteClaimedOrganizationUserAccountCommand>();
services.AddScoped<IDeleteClaimedOrganizationUserAccountValidator, DeleteClaimedOrganizationUserAccountValidator>();

View File

@@ -1,6 +1,4 @@
#nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
@@ -92,7 +90,7 @@ public interface IMailService
Task SendEmergencyAccessRecoveryReminder(EmergencyAccess emergencyAccess, string initiatingName, string email);
Task SendEmergencyAccessRecoveryTimedOut(EmergencyAccess ea, string initiatingName, string email);
Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage);
Task SendAdminResetPasswordEmailAsync(string email, string userName, string orgName);
Task SendAdminResetPasswordEmailAsync(string email, string? userName, string orgName);
Task SendProviderSetupInviteEmailAsync(Provider provider, string token, string email);
Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email);
Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email);

View File

@@ -674,7 +674,7 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendAdminResetPasswordEmailAsync(string email, string userName, string orgName)
public async Task SendAdminResetPasswordEmailAsync(string email, string? userName, string orgName)
{
var message = CreateDefaultMessage("Your admin has initiated account recovery", email);
var model = new AdminResetPasswordViewModel()

View File

@@ -221,7 +221,7 @@ public class NoopMailService : IMailService
return Task.FromResult(0);
}
public Task SendAdminResetPasswordEmailAsync(string email, string userName, string orgName)
public Task SendAdminResetPasswordEmailAsync(string email, string? userName, string orgName)
{
return Task.FromResult(0);
}