1
0
mirror of https://github.com/bitwarden/server synced 2026-01-15 06:53:26 +00:00

Merge branch 'main' into tools/pm-21918/send-authentication-commands

This commit is contained in:
Daniel James Smith
2025-09-26 12:16:17 +02:00
committed by GitHub
112 changed files with 3404 additions and 1730 deletions

View File

@@ -22,7 +22,9 @@ on:
required: false
type: string
permissions: {}
permissions:
pull-requests: write
contents: write
jobs:
setup:
@@ -231,5 +233,10 @@ jobs:
move_edd_db_scripts:
name: Move EDD database scripts
needs: cut_branch
permissions:
actions: read
contents: write
id-token: write
pull-requests: write
uses: ./.github/workflows/_move_edd_db_scripts.yml
secrets: inherit

72
CLAUDE.md Normal file
View File

@@ -0,0 +1,72 @@
# Bitwarden Server - Claude Code Configuration
## Critical Rules
- **NEVER** edit: `/bin/`, `/obj/`, `/.git/`, `/.vs/`, `/packages/` which are generated files
- **NEVER** use code regions: If complexity suggests regions, refactor for better readability
- **NEVER** compromise zero-knowledge principles: User vault data must remain encrypted and inaccessible to Bitwarden
- **NEVER** log or expose sensitive data: No PII, passwords, keys, or vault data in logs or error messages
- **ALWAYS** use secure communication channels: Enforce confidentiality, integrity, and authenticity
- **ALWAYS** encrypt sensitive data: All vault data must be encrypted at rest, in transit, and in use
- **ALWAYS** prioritize cryptographic integrity and data protection
- **ALWAYS** add unit tests (with mocking) for any new feature development
## Project Context
- **Architecture**: Feature and team-based organization
- **Framework**: .NET 8.0, ASP.NET Core
- **Database**: SQL Server primary, EF Core supports PostgreSQL, MySQL/MariaDB, SQLite
- **Testing**: xUnit, NSubstitute
- **Container**: Docker, Docker Compose, Kubernetes/Helm deployable
## Project Structure
- **Source Code**: `/src/` - Services and core infrastructure
- **Tests**: `/test/` - Test logic aligning with the source structure, albeit with a `.Test` suffix
- **Utilities**: `/util/` - Migration tools, seeders, and setup scripts
- **Dev Tools**: `/dev/` - Local development helpers
- **Configuration**: `appsettings.{Environment}.json`, `/dev/secrets.json` for local development
## Security Requirements
- **Compliance**: SOC 2 Type II, SOC 3, HIPAA, ISO 27001, GDPR, CCPA
- **Principles**: Zero-knowledge, end-to-end encryption, secure defaults
- **Validation**: Input sanitization, parameterized queries, rate limiting
- **Logging**: Structured logs, no PII/sensitive data in logs
## Common Commands
- **Build**: `dotnet build`
- **Test**: `dotnet test`
- **Run locally**: `dotnet run --project src/Api`
- **Database update**: `pwsh dev/migrate.ps1`
- **Generate OpenAPI**: `pwsh dev/generate_openapi_files.ps1`
## Code Review Checklist
- Security impact assessed
- xUnit tests added / updated
- Performance impact considered
- Error handling implemented
- Breaking changes documented
- CI passes: build, test, lint
- Feature flags considered for new features
- CODEOWNERS file respected
### Key Architectural Decisions
- Use .NET nullable reference types (ADR 0024)
- TryAdd dependency injection pattern (ADR 0026)
- Authorization patterns (ADR 0022)
- OpenTelemetry for observability (ADR 0020)
- Log to standard output (ADR 0021)
## References
- [Server architecture](https://contributing.bitwarden.com/architecture/server/)
- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/)
- [Contributing guidelines](https://contributing.bitwarden.com/contributing/)
- [Setup guide](https://contributing.bitwarden.com/getting-started/server/guide/)
- [Code style](https://contributing.bitwarden.com/contributing/code-style/)
- [Bitwarden security whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/)
- [Bitwarden security definitions](https://contributing.bitwarden.com/architecture/security/definitions)

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.9.1</Version>
<Version>2025.9.2</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -10,6 +10,7 @@ using Stripe.Tax;
namespace Bit.Commercial.Core.Billing.Providers.Queries;
using static Bit.Core.Constants;
using static StripeConstants;
using SuspensionWarning = ProviderWarnings.SuspensionWarning;
using TaxIdWarning = ProviderWarnings.TaxIdWarning;
@@ -61,6 +62,11 @@ public class GetProviderWarningsQuery(
Provider provider,
Customer customer)
{
if (customer.Address?.Country == CountryAbbreviations.UnitedStates)
{
return null;
}
if (!currentContext.ProviderProviderAdmin(provider.Id))
{
return null;
@@ -75,7 +81,7 @@ public class GetProviderWarningsQuery(
.SelectMany(registrations => registrations.Data);
// Find the matching registration for the customer
var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address.Country);
var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address?.Country);
// If we're not registered in their country, we don't need a warning
if (registration == null)

View File

@@ -58,7 +58,7 @@ public class GetProviderWarningsQueryTests
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
Address = new Address { Country = "CA" }
}
});
@@ -90,7 +90,7 @@ public class GetProviderWarningsQueryTests
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
Address = new Address { Country = "CA" }
}
});
@@ -124,7 +124,7 @@ public class GetProviderWarningsQueryTests
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
Address = new Address { Country = "CA" }
}
});
@@ -158,7 +158,7 @@ public class GetProviderWarningsQueryTests
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
Address = new Address { Country = "CA" }
}
});
@@ -191,7 +191,7 @@ public class GetProviderWarningsQueryTests
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
Address = new Address { Country = "CA" }
}
});
@@ -219,7 +219,7 @@ public class GetProviderWarningsQueryTests
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
Address = new Address { Country = "CA" }
}
});
@@ -227,7 +227,7 @@ public class GetProviderWarningsQueryTests
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
Data = [new Registration { Country = "GB" }]
});
var response = await sutProvider.Sut.Run(provider);
@@ -252,7 +252,7 @@ public class GetProviderWarningsQueryTests
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
Address = new Address { Country = "CA" }
}
});
@@ -260,7 +260,7 @@ public class GetProviderWarningsQueryTests
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "US" }]
Data = [new Registration { Country = "CA" }]
});
var response = await sutProvider.Sut.Run(provider);
@@ -291,7 +291,7 @@ public class GetProviderWarningsQueryTests
{
Data = [new TaxId { Verification = null }]
},
Address = new Address { Country = "US" }
Address = new Address { Country = "CA" }
}
});
@@ -299,7 +299,7 @@ public class GetProviderWarningsQueryTests
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "US" }]
Data = [new Registration { Country = "CA" }]
});
var response = await sutProvider.Sut.Run(provider);
@@ -333,7 +333,7 @@ public class GetProviderWarningsQueryTests
}
}]
},
Address = new Address { Country = "US" }
Address = new Address { Country = "CA" }
}
});
@@ -341,7 +341,7 @@ public class GetProviderWarningsQueryTests
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "US" }]
Data = [new Registration { Country = "CA" }]
});
var response = await sutProvider.Sut.Run(provider);
@@ -378,7 +378,7 @@ public class GetProviderWarningsQueryTests
}
}]
},
Address = new Address { Country = "US" }
Address = new Address { Country = "CA" }
}
});
@@ -386,7 +386,7 @@ public class GetProviderWarningsQueryTests
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "US" }]
Data = [new Registration { Country = "CA" }]
});
var response = await sutProvider.Sut.Run(provider);
@@ -423,7 +423,7 @@ public class GetProviderWarningsQueryTests
}
}]
},
Address = new Address { Country = "US" }
Address = new Address { Country = "CA" }
}
});
@@ -431,7 +431,7 @@ public class GetProviderWarningsQueryTests
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "US" }]
Data = [new Registration { Country = "CA" }]
});
var response = await sutProvider.Sut.Run(provider);
@@ -498,6 +498,44 @@ public class GetProviderWarningsQueryTests
Status = SubscriptionStatus.Unpaid,
CancelAt = cancelAt,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "CA" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
Suspension.Resolution: "add_payment_method",
TaxId.Type: "tax_id_missing"
});
Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt);
}
[Theory, BitAutoData]
public async Task Run_USCustomer_NoTaxIdWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
@@ -513,11 +551,6 @@ public class GetProviderWarningsQueryTests
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
Suspension.Resolution: "add_payment_method",
TaxId.Type: "tax_id_missing"
});
Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt);
Assert.Null(response!.TaxId);
}
}

View File

@@ -11,7 +11,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.OrganizationUsers.DeleteClaimedAccountvNext;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
@@ -61,7 +61,6 @@ public class OrganizationUsersController : Controller
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IDeleteClaimedOrganizationUserAccountCommand _deleteClaimedOrganizationUserAccountCommand;
private readonly IDeleteClaimedOrganizationUserAccountCommandvNext _deleteClaimedOrganizationUserAccountCommandvNext;
private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IFeatureService _featureService;
@@ -90,7 +89,6 @@ public class OrganizationUsersController : Controller
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IDeleteClaimedOrganizationUserAccountCommand deleteClaimedOrganizationUserAccountCommand,
IDeleteClaimedOrganizationUserAccountCommandvNext deleteClaimedOrganizationUserAccountCommandvNext,
IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService,
@@ -119,7 +117,6 @@ public class OrganizationUsersController : Controller
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_deleteClaimedOrganizationUserAccountCommand = deleteClaimedOrganizationUserAccountCommand;
_deleteClaimedOrganizationUserAccountCommandvNext = deleteClaimedOrganizationUserAccountCommandvNext;
_getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery;
_policyRequirementQuery = policyRequirementQuery;
_featureService = featureService;
@@ -539,21 +536,22 @@ public class OrganizationUsersController : Controller
[HttpDelete("{id}/delete-account")]
[Authorize<ManageUsersRequirement>]
public async Task DeleteAccount(Guid orgId, Guid id)
public async Task<IResult> DeleteAccount(Guid orgId, Guid id)
{
if (_featureService.IsEnabled(FeatureFlagKeys.DeleteClaimedUserAccountRefactor))
var currentUserId = _userService.GetProperUserId(User);
if (currentUserId == null)
{
await DeleteAccountvNext(orgId, id);
return;
return TypedResults.Unauthorized();
}
var currentUser = await _userService.GetUserByPrincipalAsync(User);
if (currentUser == null)
{
throw new UnauthorizedAccessException();
}
var commandResult = await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUserId.Value);
await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
return commandResult.Result.Match<IResult>(
error => error is NotFoundError
? TypedResults.NotFound(new ErrorResponseModel(error.Message))
: TypedResults.BadRequest(new ErrorResponseModel(error.Message)),
TypedResults.Ok
);
}
[HttpPost("{id}/delete-account")]
@@ -564,43 +562,24 @@ public class OrganizationUsersController : Controller
await DeleteAccount(orgId, id);
}
private async Task<IResult> DeleteAccountvNext(Guid orgId, Guid id)
{
var currentUserId = _userService.GetProperUserId(User);
if (currentUserId == null)
{
return TypedResults.Unauthorized();
}
var commandResult = await _deleteClaimedOrganizationUserAccountCommandvNext.DeleteUserAsync(orgId, id, currentUserId.Value);
return commandResult.Result.Match<IResult>(
error => error is NotFoundError
? TypedResults.NotFound(new ErrorResponseModel(error.Message))
: TypedResults.BadRequest(new ErrorResponseModel(error.Message)),
TypedResults.Ok
);
}
[HttpDelete("delete-account")]
[Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
if (_featureService.IsEnabled(FeatureFlagKeys.DeleteClaimedUserAccountRefactor))
{
return await BulkDeleteAccountvNext(orgId, model);
}
var currentUser = await _userService.GetUserByPrincipalAsync(User);
if (currentUser == null)
var currentUserId = _userService.GetProperUserId(User);
if (currentUserId == null)
{
throw new UnauthorizedAccessException();
}
var results = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
var result = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUserId.Value);
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage)));
var responses = result.Select(r => r.Result.Match(
error => new OrganizationUserBulkResponseModel(r.Id, error.Message),
_ => new OrganizationUserBulkResponseModel(r.Id, string.Empty)
));
return new ListResponseModel<OrganizationUserBulkResponseModel>(responses);
}
[HttpPost("delete-account")]
@@ -611,24 +590,6 @@ public class OrganizationUsersController : Controller
return await BulkDeleteAccount(orgId, model);
}
private async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccountvNext(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
var currentUserId = _userService.GetProperUserId(User);
if (currentUserId == null)
{
throw new UnauthorizedAccessException();
}
var result = await _deleteClaimedOrganizationUserAccountCommandvNext.DeleteManyUsersAsync(orgId, model.Ids, currentUserId.Value);
var responses = result.Select(r => r.Result.Match(
error => new OrganizationUserBulkResponseModel(r.Id, error.Message),
_ => new OrganizationUserBulkResponseModel(r.Id, string.Empty)
));
return new ListResponseModel<OrganizationUserBulkResponseModel>(responses);
}
[HttpPut("{id}/revoke")]
[Authorize<ManageUsersRequirement>]
public async Task RevokeAsync(Guid orgId, Guid id)

View File

@@ -31,7 +31,7 @@ public class MemberCreateRequestModel : MemberUpdateRequestModel
{
Emails = new[] { Email },
Type = Type.Value,
Collections = Collections?.Select(c => c.ToCollectionAccessSelection()).ToList(),
Collections = Collections?.Select(c => c.ToCollectionAccessSelection())?.ToList() ?? [],
Groups = Groups
};

View File

@@ -9,6 +9,7 @@ using Bit.Core;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Services;
using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces;
@@ -16,6 +17,7 @@ using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Kdf;
using Bit.Core.Models.Api.Response;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -26,7 +28,7 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Auth.Controllers;
[Route("accounts")]
[Authorize("Application")]
[Authorize(Policies.Application)]
public class AccountsController : Controller
{
private readonly IOrganizationService _organizationService;
@@ -39,7 +41,7 @@ public class AccountsController : Controller
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IFeatureService _featureService;
private readonly ITwoFactorEmailService _twoFactorEmailService;
private readonly IChangeKdfCommand _changeKdfCommand;
public AccountsController(
IOrganizationService organizationService,
@@ -51,7 +53,8 @@ public class AccountsController : Controller
ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IFeatureService featureService,
ITwoFactorEmailService twoFactorEmailService
ITwoFactorEmailService twoFactorEmailService,
IChangeKdfCommand changeKdfCommand
)
{
_organizationService = organizationService;
@@ -64,7 +67,7 @@ public class AccountsController : Controller
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_featureService = featureService;
_twoFactorEmailService = twoFactorEmailService;
_changeKdfCommand = changeKdfCommand;
}
@@ -256,7 +259,7 @@ public class AccountsController : Controller
}
[HttpPost("kdf")]
public async Task PostKdf([FromBody] KdfRequestModel model)
public async Task PostKdf([FromBody] PasswordRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
@@ -264,8 +267,12 @@ public class AccountsController : Controller
throw new UnauthorizedAccessException();
}
var result = await _userService.ChangeKdfAsync(user, model.MasterPasswordHash,
model.NewMasterPasswordHash, model.Key, model.Kdf.Value, model.KdfIterations.Value, model.KdfMemory, model.KdfParallelism);
if (model.AuthenticationData == null || model.UnlockData == null)
{
throw new BadRequestException("AuthenticationData and UnlockData must be provided.");
}
var result = await _changeKdfCommand.ChangeKdfAsync(user, model.MasterPasswordHash, model.AuthenticationData.ToData(), model.UnlockData.ToData());
if (result.Succeeded)
{
return;

View File

@@ -5,6 +5,7 @@ using Bit.Api.Auth.Models.Response;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
using Bit.Core.Auth.Services;
using Bit.Core.Exceptions;
@@ -18,7 +19,7 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Auth.Controllers;
[Route("auth-requests")]
[Authorize("Application")]
[Authorize(Policies.Application)]
public class AuthRequestsController(
IUserService userService,
IAuthRequestRepository authRequestRepository,
@@ -102,7 +103,37 @@ public class AuthRequestsController(
public async Task<AuthRequestResponseModel> Put(Guid id, [FromBody] AuthRequestUpdateRequestModel model)
{
var userId = _userService.GetProperUserId(User).Value;
// If the Approving Device is attempting to approve a request, validate the approval
if (model.RequestApproved == true)
{
await ValidateApprovalOfMostRecentAuthRequest(id, userId);
}
var authRequest = await _authRequestService.UpdateAuthRequestAsync(id, userId, model);
return new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault);
}
private async Task ValidateApprovalOfMostRecentAuthRequest(Guid id, Guid userId)
{
// Get the current auth request to find the device identifier
var currentAuthRequest = await _authRequestService.GetAuthRequestAsync(id, userId);
if (currentAuthRequest == null)
{
throw new NotFoundException();
}
// Get all pending auth requests for this user (returns most recent per device)
var pendingRequests = await _authRequestRepository.GetManyPendingAuthRequestByUserId(userId);
// Find the most recent request for the same device
var mostRecentForDevice = pendingRequests
.FirstOrDefault(pendingRequest => pendingRequest.RequestDeviceIdentifier == currentAuthRequest.RequestDeviceIdentifier);
var isMostRecentRequestForDevice = mostRecentForDevice?.Id == id;
if (!isMostRecentRequestForDevice)
{
throw new BadRequestException("This request is no longer valid. Make sure to approve the most recent request.");
}
}
}

View File

@@ -18,7 +18,7 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Auth.Controllers;
[Route("emergency-access")]
[Authorize("Application")]
[Authorize(Core.Auth.Identity.Policies.Application)]
public class EmergencyAccessController : Controller
{
private readonly IUserService _userService;

View File

@@ -7,6 +7,7 @@ using Bit.Api.Auth.Models.Response.TwoFactor;
using Bit.Api.Models.Request;
using Bit.Api.Models.Response;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
using Bit.Core.Auth.Models.Business.Tokenables;
@@ -26,7 +27,7 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Auth.Controllers;
[Route("two-factor")]
[Authorize("Web")]
[Authorize(Policies.Web)]
public class TwoFactorController : Controller
{
private readonly IUserService _userService;

View File

@@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models.Api.Response.Accounts;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
@@ -20,7 +21,7 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Auth.Controllers;
[Route("webauthn")]
[Authorize("Web")]
[Authorize(Policies.Web)]
public class WebAuthnController : Controller
{
private readonly IUserService _userService;

View File

@@ -1,25 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.Api.Auth.Models.Request.Accounts;
public class KdfRequestModel : PasswordRequestModel, IValidatableObject
{
[Required]
public KdfType? Kdf { get; set; }
[Required]
public int? KdfIterations { get; set; }
public int? KdfMemory { get; set; }
public int? KdfParallelism { get; set; }
public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Kdf.HasValue && KdfIterations.HasValue)
{
return KdfSettingsValidator.Validate(Kdf.Value, KdfIterations.Value, KdfMemory, KdfParallelism);
}
return Enumerable.Empty<ValidationResult>();
}
}

View File

@@ -7,7 +7,7 @@ using Bit.Core.Utilities;
namespace Bit.Api.Auth.Models.Request.Accounts;
public class MasterPasswordUnlockDataModel : IValidatableObject
public class MasterPasswordUnlockAndAuthenticationDataModel : IValidatableObject
{
public required KdfType KdfType { get; set; }
public required int KdfIterations { get; set; }
@@ -45,9 +45,9 @@ public class MasterPasswordUnlockDataModel : IValidatableObject
}
}
public MasterPasswordUnlockData ToUnlockData()
public MasterPasswordUnlockAndAuthenticationData ToUnlockData()
{
var data = new MasterPasswordUnlockData
var data = new MasterPasswordUnlockAndAuthenticationData
{
KdfType = KdfType,
KdfIterations = KdfIterations,

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
#nullable enable
using System.ComponentModel.DataAnnotations;
using Bit.Api.KeyManagement.Models.Requests;
namespace Bit.Api.Auth.Models.Request.Accounts;
@@ -9,9 +9,13 @@ public class PasswordRequestModel : SecretVerificationRequestModel
{
[Required]
[StringLength(300)]
public string NewMasterPasswordHash { get; set; }
public required string NewMasterPasswordHash { get; set; }
[StringLength(50)]
public string MasterPasswordHint { get; set; }
public string? MasterPasswordHint { get; set; }
[Required]
public string Key { get; set; }
public required string Key { get; set; }
// Note: These will eventually become required, but not all consumers are moved over yet.
public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; }
public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; }
}

View File

@@ -211,18 +211,6 @@ public class OrganizationsController(
return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result };
}
[HttpPost("{id:guid}/verify-bank")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostVerifyBank(Guid id, [FromBody] OrganizationVerifyBankRequestModel model)
{
if (!await currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
await organizationService.VerifyBankAsync(id, model.Amount1.Value, model.Amount2.Value);
}
[HttpPost("{id}/cancel")]
public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request)
{

View File

@@ -199,7 +199,7 @@ public class CollectionsController : Controller
[HttpPost("{id}")]
[Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")]
public async Task<CollectionResponseModel> Post(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model)
public async Task<CollectionResponseModel> PostPut(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model)
{
return await Put(orgId, id, model);
}

View File

@@ -115,7 +115,7 @@ public class DevicesController : Controller
[HttpPost("{id}")]
[Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")]
public async Task<DeviceResponseModel> Post(string id, [FromBody] DeviceRequestModel model)
public async Task<DeviceResponseModel> PostPut(string id, [FromBody] DeviceRequestModel model)
{
return await Put(id, model);
}

View File

@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Data;
namespace Bit.Api.KeyManagement.Models.Requests;
public class KdfRequestModel
{
[Required]
public required KdfType KdfType { get; init; }
[Required]
public required int Iterations { get; init; }
public int? Memory { get; init; }
public int? Parallelism { get; init; }
public KdfSettings ToData()
{
return new KdfSettings
{
KdfType = KdfType,
Iterations = Iterations,
Memory = Memory,
Parallelism = Parallelism
};
}
}

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.KeyManagement.Models.Data;
namespace Bit.Api.KeyManagement.Models.Requests;
public class MasterPasswordAuthenticationDataRequestModel
{
public required KdfRequestModel Kdf { get; init; }
public required string MasterPasswordAuthenticationHash { get; init; }
[StringLength(256)] public required string Salt { get; init; }
public MasterPasswordAuthenticationData ToData()
{
return new MasterPasswordAuthenticationData
{
Kdf = Kdf.ToData(),
MasterPasswordAuthenticationHash = MasterPasswordAuthenticationHash,
Salt = Salt
};
}
}

View File

@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.KeyManagement.Models.Requests;
public class MasterPasswordUnlockDataRequestModel
{
public required KdfRequestModel Kdf { get; init; }
[EncryptedString] public required string MasterKeyWrappedUserKey { get; init; }
[StringLength(256)] public required string Salt { get; init; }
public MasterPasswordUnlockData ToData()
{
return new MasterPasswordUnlockData
{
Kdf = Kdf.ToData(),
MasterKeyWrappedUserKey = MasterKeyWrappedUserKey,
Salt = Salt
};
}
}

View File

@@ -10,7 +10,7 @@ namespace Bit.Api.KeyManagement.Models.Requests;
public class UnlockDataRequestModel
{
// All methods to get to the userkey
public required MasterPasswordUnlockDataModel MasterPasswordUnlockData { get; set; }
public required MasterPasswordUnlockAndAuthenticationDataModel MasterPasswordUnlockData { get; set; }
public required IEnumerable<EmergencyAccessWithIdRequestModel> EmergencyAccessUnlockData { get; set; }
public required IEnumerable<ResetPasswordWithOrgIdRequestModel> OrganizationAccountRecoveryUnlockData { get; set; }
public required IEnumerable<WebAuthnLoginRotateKeyRequestModel> PasskeyUnlockData { get; set; }

View File

@@ -34,6 +34,7 @@ using Bit.Core.Dirt.Reports.ReportFeatures;
using Bit.Core.Tools.SendFeatures;
using Bit.Core.Auth.IdentityServer;
using Bit.Core.Auth.Identity;
using Bit.Core.Enums;
#if !OSS
@@ -105,40 +106,40 @@ public class Startup
services.AddCustomIdentityServices(globalSettings);
services.AddIdentityAuthenticationServices(globalSettings, Environment, config =>
{
config.AddPolicy("Application", policy =>
config.AddPolicy(Policies.Application, policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim(JwtClaimTypes.AuthenticationMethod, "Application", "external");
policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.Api);
});
config.AddPolicy("Web", policy =>
config.AddPolicy(Policies.Web, policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim(JwtClaimTypes.AuthenticationMethod, "Application", "external");
policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.Api);
policy.RequireClaim(JwtClaimTypes.ClientId, "web");
policy.RequireClaim(JwtClaimTypes.ClientId, BitwardenClient.Web);
});
config.AddPolicy("Push", policy =>
config.AddPolicy(Policies.Push, policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiPush);
});
config.AddPolicy("Licensing", policy =>
config.AddPolicy(Policies.Licensing, policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiLicensing);
});
config.AddPolicy("Organization", policy =>
config.AddPolicy(Policies.Organization, policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiOrganization);
});
config.AddPolicy("Installation", policy =>
config.AddPolicy(Policies.Installation, policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiInstallation);
});
config.AddPolicy("Secrets", policy =>
config.AddPolicy(Policies.Secrets, policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(ctx => ctx.User.HasClaim(c =>

View File

@@ -336,13 +336,15 @@ public class CiphersController : Controller
}
[HttpGet("organization-details")]
public async Task<ListResponseModel<CipherMiniDetailsResponseModel>> GetOrganizationCiphers(Guid organizationId)
public async Task<ListResponseModel<CipherMiniDetailsResponseModel>> GetOrganizationCiphers(Guid organizationId, bool includeMemberItems = false)
{
if (!await CanAccessAllCiphersAsync(organizationId))
{
throw new NotFoundException();
}
var allOrganizationCiphers = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
bool excludeDefaultUserCollections = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) && !includeMemberItems;
var allOrganizationCiphers = excludeDefaultUserCollections
?
await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(organizationId)
:

View File

@@ -7,8 +7,6 @@ using Bit.Core.Utilities;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
using Bit.Core.Vault.Models.Data;
using NS = Newtonsoft.Json;
using NSL = Newtonsoft.Json.Linq;
namespace Bit.Api.Vault.Models.Request;
@@ -40,11 +38,26 @@ public class CipherRequestModel
// TODO: Rename to Attachments whenever the above is finally removed.
public Dictionary<string, CipherAttachmentModel> Attachments2 { get; set; }
[Obsolete("Use Data instead.")]
public CipherLoginModel Login { get; set; }
[Obsolete("Use Data instead.")]
public CipherCardModel Card { get; set; }
[Obsolete("Use Data instead.")]
public CipherIdentityModel Identity { get; set; }
[Obsolete("Use Data instead.")]
public CipherSecureNoteModel SecureNote { get; set; }
[Obsolete("Use Data instead.")]
public CipherSSHKeyModel SSHKey { get; set; }
/// <summary>
/// JSON string containing cipher-specific data
/// </summary>
[StringLength(500000)]
public string Data { get; set; }
public DateTime? LastKnownRevisionDate { get; set; } = null;
public DateTime? ArchivedDate { get; set; }
@@ -73,29 +86,42 @@ public class CipherRequestModel
public Cipher ToCipher(Cipher existingCipher)
{
switch (existingCipher.Type)
// If Data field is provided, use it directly
if (!string.IsNullOrWhiteSpace(Data))
{
case CipherType.Login:
var loginObj = NSL.JObject.FromObject(ToCipherLoginData(),
new NS.JsonSerializer { NullValueHandling = NS.NullValueHandling.Ignore });
// TODO: Switch to JsonNode in .NET 6 https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-use-dom-utf8jsonreader-utf8jsonwriter?pivots=dotnet-6-0
loginObj[nameof(CipherLoginData.Uri)]?.Parent?.Remove();
existingCipher.Data = loginObj.ToString(NS.Formatting.None);
break;
case CipherType.Card:
existingCipher.Data = JsonSerializer.Serialize(ToCipherCardData(), JsonHelpers.IgnoreWritingNull);
break;
case CipherType.Identity:
existingCipher.Data = JsonSerializer.Serialize(ToCipherIdentityData(), JsonHelpers.IgnoreWritingNull);
break;
case CipherType.SecureNote:
existingCipher.Data = JsonSerializer.Serialize(ToCipherSecureNoteData(), JsonHelpers.IgnoreWritingNull);
break;
case CipherType.SSHKey:
existingCipher.Data = JsonSerializer.Serialize(ToCipherSSHKeyData(), JsonHelpers.IgnoreWritingNull);
break;
default:
throw new ArgumentException("Unsupported type: " + nameof(Type) + ".");
existingCipher.Data = Data;
}
else
{
// Fallback to structured fields
switch (existingCipher.Type)
{
case CipherType.Login:
var loginData = ToCipherLoginData();
var loginJson = JsonSerializer.Serialize(loginData, JsonHelpers.IgnoreWritingNull);
var loginObj = JsonDocument.Parse(loginJson);
var loginDict = JsonSerializer.Deserialize<Dictionary<string, object>>(loginJson);
loginDict?.Remove(nameof(CipherLoginData.Uri));
existingCipher.Data = JsonSerializer.Serialize(loginDict, JsonHelpers.IgnoreWritingNull);
break;
case CipherType.Card:
existingCipher.Data = JsonSerializer.Serialize(ToCipherCardData(), JsonHelpers.IgnoreWritingNull);
break;
case CipherType.Identity:
existingCipher.Data =
JsonSerializer.Serialize(ToCipherIdentityData(), JsonHelpers.IgnoreWritingNull);
break;
case CipherType.SecureNote:
existingCipher.Data =
JsonSerializer.Serialize(ToCipherSecureNoteData(), JsonHelpers.IgnoreWritingNull);
break;
case CipherType.SSHKey:
existingCipher.Data = JsonSerializer.Serialize(ToCipherSSHKeyData(), JsonHelpers.IgnoreWritingNull);
break;
default:
throw new ArgumentException("Unsupported type: " + nameof(Type) + ".");
}
}
existingCipher.Reprompt = Reprompt;

View File

@@ -24,6 +24,7 @@ public class CipherMiniResponseModel : ResponseModel
Id = cipher.Id;
Type = cipher.Type;
Data = cipher.Data;
CipherData cipherData;
switch (cipher.Type)
@@ -31,30 +32,25 @@ public class CipherMiniResponseModel : ResponseModel
case CipherType.Login:
var loginData = JsonSerializer.Deserialize<CipherLoginData>(cipher.Data);
cipherData = loginData;
Data = loginData;
Login = new CipherLoginModel(loginData);
break;
case CipherType.SecureNote:
var secureNoteData = JsonSerializer.Deserialize<CipherSecureNoteData>(cipher.Data);
Data = secureNoteData;
cipherData = secureNoteData;
SecureNote = new CipherSecureNoteModel(secureNoteData);
break;
case CipherType.Card:
var cardData = JsonSerializer.Deserialize<CipherCardData>(cipher.Data);
Data = cardData;
cipherData = cardData;
Card = new CipherCardModel(cardData);
break;
case CipherType.Identity:
var identityData = JsonSerializer.Deserialize<CipherIdentityData>(cipher.Data);
Data = identityData;
cipherData = identityData;
Identity = new CipherIdentityModel(identityData);
break;
case CipherType.SSHKey:
var sshKeyData = JsonSerializer.Deserialize<CipherSSHKeyData>(cipher.Data);
Data = sshKeyData;
cipherData = sshKeyData;
SSHKey = new CipherSSHKeyModel(sshKeyData);
break;
@@ -80,15 +76,33 @@ public class CipherMiniResponseModel : ResponseModel
public Guid Id { get; set; }
public Guid? OrganizationId { get; set; }
public CipherType Type { get; set; }
public dynamic Data { get; set; }
public string Data { get; set; }
[Obsolete("Use Data instead.")]
public string Name { get; set; }
[Obsolete("Use Data instead.")]
public string Notes { get; set; }
[Obsolete("Use Data instead.")]
public CipherLoginModel Login { get; set; }
[Obsolete("Use Data instead.")]
public CipherCardModel Card { get; set; }
[Obsolete("Use Data instead.")]
public CipherIdentityModel Identity { get; set; }
[Obsolete("Use Data instead.")]
public CipherSecureNoteModel SecureNote { get; set; }
[Obsolete("Use Data instead.")]
public CipherSSHKeyModel SSHKey { get; set; }
[Obsolete("Use Data instead.")]
public IEnumerable<CipherFieldModel> Fields { get; set; }
[Obsolete("Use Data instead.")]
public IEnumerable<CipherPasswordHistoryModel> PasswordHistory { get; set; }
public IEnumerable<AttachmentResponseModel> Attachments { get; set; }
public bool OrganizationUseTotp { get; set; }

View File

@@ -18,6 +18,8 @@ public enum PolicyType : byte
FreeFamiliesSponsorshipPolicy = 13,
RemoveUnlockWithPin = 14,
RestrictedItemTypesPolicy = 15,
UriMatchDefaults = 16,
AutotypeDefaultSetting = 17,
}
public static class PolicyTypeExtensions
@@ -46,6 +48,8 @@ public static class PolicyTypeExtensions
PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship",
PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN",
PolicyType.RestrictedItemTypesPolicy => "Restricted item types",
PolicyType.UriMatchDefaults => "URI match defaults",
PolicyType.AutotypeDefaultSetting => "Autotype default setting",
};
}
}

View File

@@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -82,7 +83,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
throw new BadRequestException(error);
}
await HandleConfirmationSideEffectsAsync(organizationId, confirmedOrganizationUsers: [orgUser], defaultUserCollectionName);
await CreateDefaultCollectionAsync(orgUser, defaultUserCollectionName);
return orgUser;
}
@@ -97,9 +98,13 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
.Select(r => r.Item1)
.ToList();
if (confirmedOrganizationUsers.Count > 0)
if (confirmedOrganizationUsers.Count == 1)
{
await HandleConfirmationSideEffectsAsync(organizationId, confirmedOrganizationUsers, defaultUserCollectionName);
await CreateDefaultCollectionAsync(confirmedOrganizationUsers.Single(), defaultUserCollectionName);
}
else if (confirmedOrganizationUsers.Count > 1)
{
await CreateManyDefaultCollectionsAsync(organizationId, confirmedOrganizationUsers, defaultUserCollectionName);
}
return result;
@@ -245,14 +250,54 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
}
/// <summary>
/// Handles the side effects of confirming an organization user.
/// Creates a default collection for the user if the organization
/// has the OrganizationDataOwnership policy enabled.
/// Creates a default collection for a single user if required by the Organization Data Ownership policy.
/// </summary>
/// <param name="organizationUser">The organization user who has just been confirmed.</param>
/// <param name="defaultUserCollectionName">The encrypted default user collection name.</param>
private async Task CreateDefaultCollectionAsync(OrganizationUser organizationUser, string defaultUserCollectionName)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation))
{
return;
}
// Skip if no collection name provided (backwards compatibility)
if (string.IsNullOrWhiteSpace(defaultUserCollectionName))
{
return;
}
var organizationDataOwnershipPolicy =
await _policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(organizationUser.UserId!.Value);
if (!organizationDataOwnershipPolicy.RequiresDefaultCollectionOnConfirm(organizationUser.OrganizationId))
{
return;
}
var defaultCollection = new Collection
{
OrganizationId = organizationUser.OrganizationId,
Name = defaultUserCollectionName,
Type = CollectionType.DefaultUserCollection
};
var collectionUser = new CollectionAccessSelection
{
Id = organizationUser.Id,
ReadOnly = false,
HidePasswords = false,
Manage = true
};
await _collectionRepository.CreateAsync(defaultCollection, groups: null, users: [collectionUser]);
}
/// <summary>
/// Creates default collections for multiple users if required by the Organization Data Ownership policy.
/// </summary>
/// <param name="organizationId">The organization ID.</param>
/// <param name="confirmedOrganizationUsers">The confirmed organization users.</param>
/// <param name="defaultUserCollectionName">The encrypted default user collection name.</param>
private async Task HandleConfirmationSideEffectsAsync(Guid organizationId,
private async Task CreateManyDefaultCollectionsAsync(Guid organizationId,
IEnumerable<OrganizationUser> confirmedOrganizationUsers, string defaultUserCollectionName)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation))
@@ -266,7 +311,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
return;
}
var policyEligibleOrganizationUserIds = await _policyRequirementQuery.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(organizationId);
var policyEligibleOrganizationUserIds =
await _policyRequirementQuery.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(organizationId);
var eligibleOrganizationUserIds = confirmedOrganizationUsers
.Where(ou => policyEligibleOrganizationUserIds.Contains(ou.Id))

View File

@@ -1,7 +1,7 @@
using OneOf;
using OneOf.Types;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
/// <summary>
/// Represents the result of a command.

View File

@@ -8,18 +8,18 @@ using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using OneOf.Types;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
public class DeleteClaimedOrganizationUserAccountCommandvNext(
public class DeleteClaimedOrganizationUserAccountCommand(
IUserService userService,
IEventService eventService,
IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
IOrganizationUserRepository organizationUserRepository,
IUserRepository userRepository,
IPushNotificationService pushService,
ILogger<DeleteClaimedOrganizationUserAccountCommandvNext> logger,
IDeleteClaimedOrganizationUserAccountValidatorvNext deleteClaimedOrganizationUserAccountValidatorvNext)
: IDeleteClaimedOrganizationUserAccountCommandvNext
ILogger<DeleteClaimedOrganizationUserAccountCommand> logger,
IDeleteClaimedOrganizationUserAccountValidator deleteClaimedOrganizationUserAccountValidator)
: IDeleteClaimedOrganizationUserAccountCommand
{
public async Task<BulkCommandResult> DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId)
{
@@ -35,7 +35,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNext(
var claimedStatuses = await getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds);
var internalRequests = CreateInternalRequests(organizationId, deletingUserId, orgUserIds, orgUsers, users, claimedStatuses);
var validationResults = (await deleteClaimedOrganizationUserAccountValidatorvNext.ValidateAsync(internalRequests)).ToList();
var validationResults = (await deleteClaimedOrganizationUserAccountValidator.ValidateAsync(internalRequests)).ToList();
var validRequests = validationResults.ValidRequests();
await CancelPremiumsAsync(validRequests);

View File

@@ -2,14 +2,14 @@
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext.ValidationResultHelpers;
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount.ValidationResultHelpers;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
public class DeleteClaimedOrganizationUserAccountValidatorvNext(
public class DeleteClaimedOrganizationUserAccountValidator(
ICurrentContext currentContext,
IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository) : IDeleteClaimedOrganizationUserAccountValidatorvNext
IProviderUserRepository providerUserRepository) : IDeleteClaimedOrganizationUserAccountValidator
{
public async Task<IEnumerable<ValidationResult<DeleteUserValidationRequest>>> ValidateAsync(IEnumerable<DeleteUserValidationRequest> requests)
{

View File

@@ -1,6 +1,6 @@
using Bit.Core.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
public class DeleteUserValidationRequest
{

View File

@@ -1,4 +1,4 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
/// <summary>
/// A strongly typed error containing a reason that an action failed.

View File

@@ -1,6 +1,6 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
public interface IDeleteClaimedOrganizationUserAccountCommandvNext
public interface IDeleteClaimedOrganizationUserAccountCommand
{
/// <summary>
/// Removes a user from an organization and deletes all of their associated user data.

View File

@@ -1,6 +1,6 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
public interface IDeleteClaimedOrganizationUserAccountValidatorvNext
public interface IDeleteClaimedOrganizationUserAccountValidator
{
Task<IEnumerable<ValidationResult<DeleteUserValidationRequest>>> ValidateAsync(IEnumerable<DeleteUserValidationRequest> requests);
}

View File

@@ -1,7 +1,7 @@
using OneOf;
using OneOf.Types;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
/// <summary>
/// Represents the result of validating a request.

View File

@@ -1,239 +0,0 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
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;
#nullable enable
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganizationUserAccountCommand
{
private readonly IUserService _userService;
private readonly IEventService _eventService;
private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IUserRepository _userRepository;
private readonly ICurrentContext _currentContext;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPushNotificationService _pushService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IProviderUserRepository _providerUserRepository;
public DeleteClaimedOrganizationUserAccountCommand(
IUserService userService,
IEventService eventService,
IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
IOrganizationUserRepository organizationUserRepository,
IUserRepository userRepository,
ICurrentContext currentContext,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPushNotificationService pushService,
IOrganizationRepository organizationRepository,
IProviderUserRepository providerUserRepository)
{
_userService = userService;
_eventService = eventService;
_getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery;
_organizationUserRepository = organizationUserRepository;
_userRepository = userRepository;
_currentContext = currentContext;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pushService = pushService;
_organizationRepository = organizationRepository;
_providerUserRepository = providerUserRepository;
}
public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId)
{
var organizationUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
if (organizationUser == null || organizationUser.OrganizationId != organizationId)
{
throw new NotFoundException("Member not found.");
}
var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, new[] { organizationUserId });
var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, new[] { organizationUserId }, includeProvider: true);
await ValidateDeleteUserAsync(organizationId, organizationUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners);
var user = await _userRepository.GetByIdAsync(organizationUser.UserId!.Value);
if (user == null)
{
throw new NotFoundException("Member not found.");
}
await _userService.DeleteAsync(user);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Deleted);
}
public async Task<IEnumerable<(Guid OrganizationUserId, string? ErrorMessage)>> DeleteManyUsersAsync(Guid organizationId, IEnumerable<Guid> orgUserIds, Guid? deletingUserId)
{
var orgUsers = await _organizationUserRepository.GetManyAsync(orgUserIds);
var userIds = orgUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId!.Value).ToList();
var users = await _userRepository.GetManyAsync(userIds);
var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds);
var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, orgUserIds, includeProvider: true);
var results = new List<(Guid OrganizationUserId, string? ErrorMessage)>();
foreach (var orgUserId in orgUserIds)
{
try
{
var orgUser = orgUsers.FirstOrDefault(ou => ou.Id == orgUserId);
if (orgUser == null || orgUser.OrganizationId != organizationId)
{
throw new NotFoundException("Member not found.");
}
await ValidateDeleteUserAsync(organizationId, orgUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners);
var user = users.FirstOrDefault(u => u.Id == orgUser.UserId);
if (user == null)
{
throw new NotFoundException("Member not found.");
}
await ValidateUserMembershipAndPremiumAsync(user);
results.Add((orgUserId, string.Empty));
}
catch (Exception ex)
{
results.Add((orgUserId, ex.Message));
}
}
var orgUserResultsToDelete = results.Where(result => string.IsNullOrEmpty(result.ErrorMessage));
var orgUsersToDelete = orgUsers.Where(orgUser => orgUserResultsToDelete.Any(result => orgUser.Id == result.OrganizationUserId));
var usersToDelete = users.Where(user => orgUsersToDelete.Any(orgUser => orgUser.UserId == user.Id));
if (usersToDelete.Any())
{
await DeleteManyAsync(usersToDelete);
}
await LogDeletedOrganizationUsersAsync(orgUsers, results);
return results;
}
private async Task ValidateDeleteUserAsync(Guid organizationId, OrganizationUser orgUser, Guid? deletingUserId, IDictionary<Guid, bool> claimedStatus, bool hasOtherConfirmedOwners)
{
if (!orgUser.UserId.HasValue || orgUser.Status == OrganizationUserStatusType.Invited)
{
throw new BadRequestException("You cannot delete a member with Invited status.");
}
if (deletingUserId.HasValue && orgUser.UserId.Value == deletingUserId.Value)
{
throw new BadRequestException("You cannot delete yourself.");
}
if (orgUser.Type == OrganizationUserType.Owner)
{
if (deletingUserId.HasValue && !await _currentContext.OrganizationOwner(organizationId))
{
throw new BadRequestException("Only owners can delete other owners.");
}
if (!hasOtherConfirmedOwners)
{
throw new BadRequestException("Organization must have at least one confirmed owner.");
}
}
if (orgUser.Type == OrganizationUserType.Admin && await _currentContext.OrganizationCustom(organizationId))
{
throw new BadRequestException("Custom users can not delete admins.");
}
if (!claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) || !isClaimed)
{
throw new BadRequestException("Member is not claimed by the organization.");
}
}
private async Task LogDeletedOrganizationUsersAsync(
IEnumerable<OrganizationUser> orgUsers,
IEnumerable<(Guid OrgUserId, string? ErrorMessage)> results)
{
var eventDate = DateTime.UtcNow;
var events = new List<(OrganizationUser OrgUser, EventType Event, DateTime? EventDate)>();
foreach (var (orgUserId, errorMessage) in results)
{
var orgUser = orgUsers.FirstOrDefault(ou => ou.Id == orgUserId);
// If the user was not found or there was an error, we skip logging the event
if (orgUser == null || !string.IsNullOrEmpty(errorMessage))
{
continue;
}
events.Add((orgUser, EventType.OrganizationUser_Deleted, eventDate));
}
if (events.Any())
{
await _eventService.LogOrganizationUserEventsAsync(events);
}
}
private async Task DeleteManyAsync(IEnumerable<User> users)
{
await _userRepository.DeleteManyAsync(users);
foreach (var user in users)
{
await _pushService.PushLogOutAsync(user.Id);
}
}
private async Task ValidateUserMembershipAndPremiumAsync(User user)
{
// Check if user is the only owner of any organizations.
var onlyOwnerCount = await _organizationUserRepository.GetCountByOnlyOwnerAsync(user.Id);
if (onlyOwnerCount > 0)
{
throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user.");
}
var orgs = await _organizationUserRepository.GetManyDetailsByUserAsync(user.Id, OrganizationUserStatusType.Confirmed);
if (orgs.Count == 1)
{
var org = await _organizationRepository.GetByIdAsync(orgs.First().OrganizationId);
if (org != null && (!org.Enabled || string.IsNullOrWhiteSpace(org.GatewaySubscriptionId)))
{
var orgCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(org.Id);
if (orgCount <= 1)
{
await _organizationRepository.DeleteAsync(org);
}
else
{
throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user.");
}
}
}
var onlyOwnerProviderCount = await _providerUserRepository.GetCountByOnlyOwnerAsync(user.Id);
if (onlyOwnerProviderCount > 0)
{
throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one provider. Please delete these providers or upgrade another user.");
}
if (!string.IsNullOrWhiteSpace(user.GatewaySubscriptionId))
{
try
{
await _userService.CancelPremiumAsync(user);
}
catch (GatewayException) { }
}
}
}

View File

@@ -1,19 +0,0 @@
#nullable enable
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
public interface IDeleteClaimedOrganizationUserAccountCommand
{
/// <summary>
/// Removes a user from an organization and deletes all of their associated user data.
/// </summary>
Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId);
/// <summary>
/// Removes multiple users from an organization and deletes all of their associated user data.
/// </summary>
/// <returns>
/// An error message for each user that could not be removed, otherwise null.
/// </returns>
Task<IEnumerable<(Guid OrganizationUserId, string? ErrorMessage)>> DeleteManyUsersAsync(Guid organizationId, IEnumerable<Guid> orgUserIds, Guid? deletingUserId);
}

View File

@@ -22,8 +22,7 @@ public class SendOrganizationInvitesCommand(
IPolicyRepository policyRepository,
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
IDataProtectorTokenFactory<OrgUserInviteTokenable> dataProtectorTokenFactory,
IMailService mailService,
IFeatureService featureService) : ISendOrganizationInvitesCommand
IMailService mailService) : ISendOrganizationInvitesCommand
{
public async Task SendInvitesAsync(SendInvitesRequest request)
{
@@ -72,15 +71,12 @@ public class SendOrganizationInvitesCommand(
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
var isSubjectFeatureEnabled = featureService.IsEnabled(FeatureFlagKeys.InviteEmailImprovements);
return new OrganizationInvitesInfo(
organization,
orgSsoEnabled,
orgSsoLoginRequiredPolicyEnabled,
orgUsersWithExpTokens,
orgUserHasExistingUserDict,
isSubjectFeatureEnabled,
initOrganization
);
}

View File

@@ -67,6 +67,11 @@ public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement
var noCollectionNeeded = new DefaultCollectionRequest(Guid.Empty, false);
return noCollectionNeeded;
}
public bool RequiresDefaultCollectionOnConfirm(Guid organizationId)
{
return _policyDetails.Any(p => p.OrganizationId == organizationId);
}
}
public record DefaultCollectionRequest(Guid OrganizationUserId, bool ShouldCreateDefaultCollection)

View File

@@ -18,7 +18,6 @@ public interface IOrganizationService
Task UpdateSubscription(Guid organizationId, int seatAdjustment, int? maxAutoscaleSeats);
Task AutoAddSeatsAsync(Organization organization, int seatsToAdd);
Task<string> AdjustSeatsAsync(Guid organizationId, int seatAdjustment);
Task VerifyBankAsync(Guid organizationId, int amount1, int amount2);
Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate);
Task UpdateAsync(Organization organization, bool updateBilling = false);
Task<Organization> UpdateCollectionManagementSettingsAsync(Guid organizationId, OrganizationCollectionManagementSettings settings);

View File

@@ -325,49 +325,6 @@ public class OrganizationService : IOrganizationService
return paymentIntentClientSecret;
}
public async Task VerifyBankAsync(Guid organizationId, int amount1, int amount2)
{
var organization = await GetOrgById(organizationId);
if (organization == null)
{
throw new NotFoundException();
}
if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
{
throw new GatewayException("Not a gateway customer.");
}
var bankService = new BankAccountService();
var customer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId,
new CustomerGetOptions { Expand = new List<string> { "sources" } });
if (customer == null)
{
throw new GatewayException("Cannot find customer.");
}
var bankAccount = customer.Sources
.FirstOrDefault(s => s is BankAccount && ((BankAccount)s).Status != "verified") as BankAccount;
if (bankAccount == null)
{
throw new GatewayException("Cannot find an unverified bank account.");
}
try
{
var result = await bankService.VerifyAsync(organization.GatewayCustomerId, bankAccount.Id,
new BankAccountVerifyOptions { Amounts = new List<long> { amount1, amount2 } });
if (result.Status != "verified")
{
throw new GatewayException("Unable to verify account.");
}
}
catch (StripeException e)
{
throw new GatewayException(e.Message);
}
}
public async Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate)
{
var org = await GetOrgById(organizationId);

View File

@@ -6,5 +6,11 @@ public static class Policies
/// Policy for managing access to the Send feature.
/// </summary>
public const string Send = "Send"; // [Authorize(Policy = Policies.Send)]
// TODO: migrate other existing policies to use this class
public const string Application = "Application"; // [Authorize(Policy = Policies.Application)]
public const string Web = "Web"; // [Authorize(Policy = Policies.Web)]
public const string Push = "Push"; // [Authorize(Policy = Policies.Push)]
public const string Licensing = "Licensing"; // [Authorize(Policy = Policies.Licensing)]
public const string Organization = "Organization"; // [Authorize(Policy = Policies.Organization)]
public const string Installation = "Installation"; // [Authorize(Policy = Policies.Installation)]
public const string Secrets = "Secrets"; // [Authorize(Policy = Policies.Secrets)]
}

View File

@@ -15,6 +15,7 @@ using Stripe.Tax;
namespace Bit.Core.Billing.Organizations.Queries;
using static Core.Constants;
using static StripeConstants;
using FreeTrialWarning = OrganizationWarnings.FreeTrialWarning;
using InactiveSubscriptionWarning = OrganizationWarnings.InactiveSubscriptionWarning;
@@ -232,6 +233,11 @@ public class GetOrganizationWarningsQuery(
Customer customer,
Provider? provider)
{
if (customer.Address?.Country == CountryAbbreviations.UnitedStates)
{
return null;
}
var productTier = organization.PlanType.GetProductTier();
// Only business tier customers can have tax IDs

View File

@@ -134,8 +134,6 @@ public static class FeatureFlagKeys
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service";
public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors";
public const string DeleteClaimedUserAccountRefactor = "pm-25094-refactor-delete-managed-organization-user-command";
public const string InviteEmailImprovements = "pm-25644-update-join-organization-subject-line";
/* Auth Team */
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
@@ -145,6 +143,9 @@ public static class FeatureFlagKeys
public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor";
public const string Otp6Digits = "pm-18612-otp-6-digits";
public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email";
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword =
"pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password";
/* Autofill Team */
public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
@@ -169,7 +170,6 @@ public static class FeatureFlagKeys
public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships";
public const string UsePricingService = "use-pricing-service";
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
public const string UseOrganizationWarningsService = "use-organization-warnings-service";
public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout";
public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover";
public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings";
@@ -186,6 +186,7 @@ public static class FeatureFlagKeys
public const string PM17987_BlockType0 = "pm-17987-block-type-0";
public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings";
public const string UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data";
public const string WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2";
/* Mobile Team */
public const string NativeCarouselFlow = "native-carousel-flow";
@@ -249,6 +250,9 @@ public static class FeatureFlagKeys
/* Innovation Team */
public const string ArchiveVaultItems = "pm-19148-innovation-archive";
/* DIRT Team */
public const string PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab";
public static List<string> GetAllKeys()
{
return typeof(FeatureFlagKeys).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)

View File

@@ -59,7 +59,7 @@
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.9.1" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.10.1" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />

View File

@@ -78,6 +78,11 @@ public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFac
public DateTime? LastEmailChangeDate { get; set; }
public bool VerifyDevices { get; set; } = true;
public string GetMasterPasswordSalt()
{
return Email.ToLowerInvariant().Trim();
}
public void SetNewId()
{
Id = CoreHelpers.GenerateComb();

View File

@@ -0,0 +1,15 @@
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Data;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.KeyManagement.Kdf;
/// <summary>
/// Command to change the Key Derivation Function (KDF) settings for a user. This includes
/// changing the masterpassword authentication hash, and the masterkey encrypted userkey.
/// The salt must not change during the KDF change.
/// </summary>
public interface IChangeKdfCommand
{
public Task<IdentityResult> ChangeKdfAsync(User user, string masterPasswordAuthenticationHash, MasterPasswordAuthenticationData authenticationData, MasterPasswordUnlockData unlockData);
}

View File

@@ -0,0 +1,94 @@
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
namespace Bit.Core.KeyManagement.Kdf.Implementations;
/// <inheritdoc />
public class ChangeKdfCommand : IChangeKdfCommand
{
private readonly IUserService _userService;
private readonly IPushNotificationService _pushService;
private readonly IUserRepository _userRepository;
private readonly IdentityErrorDescriber _identityErrorDescriber;
private readonly ILogger<ChangeKdfCommand> _logger;
public ChangeKdfCommand(IUserService userService, IPushNotificationService pushService, IUserRepository userRepository, IdentityErrorDescriber describer, ILogger<ChangeKdfCommand> logger)
{
_userService = userService;
_pushService = pushService;
_userRepository = userRepository;
_identityErrorDescriber = describer;
_logger = logger;
}
public async Task<IdentityResult> ChangeKdfAsync(User user, string masterPasswordAuthenticationHash, MasterPasswordAuthenticationData authenticationData, MasterPasswordUnlockData unlockData)
{
ArgumentNullException.ThrowIfNull(user);
if (!await _userService.CheckPasswordAsync(user, masterPasswordAuthenticationHash))
{
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
}
// Validate to prevent user account from becoming un-decryptable from invalid parameters
//
// Prevent a de-synced salt value from creating an un-decryptable unlock method
authenticationData.ValidateSaltUnchangedForUser(user);
unlockData.ValidateSaltUnchangedForUser(user);
// Currently KDF settings are not saved separately for authentication and unlock and must therefore be equal
if (!authenticationData.Kdf.Equals(unlockData.Kdf))
{
throw new BadRequestException("KDF settings must be equal for authentication and unlock.");
}
var validationErrors = KdfSettingsValidator.Validate(unlockData.Kdf);
if (validationErrors.Any())
{
throw new BadRequestException("KDF settings are invalid.");
}
// Update the user with the new KDF settings
// This updates the authentication data and unlock data for the user separately. Currently these still
// use shared values for KDF settings and salt.
// The authentication hash, and the unlock data each are dependent on:
// - The master password (entered by the user every time)
// - The KDF settings (iterations, memory, parallelism)
// - The salt
// These combinations - (password, authentication hash, KDF settings, salt) and (password, unlock data, KDF settings, salt)
// must remain consistent to unlock correctly.
// Authentication
// Note: This mutates the user but does not yet save it to DB. That is done atomically, later.
// This entire operation MUST be atomic to prevent a user from being locked out of their account.
// Salt is ensured to be the same as unlock data, and the value stored in the account and not updated.
// KDF is ensured to be the same as unlock data above and updated below.
var result = await _userService.UpdatePasswordHash(user, authenticationData.MasterPasswordAuthenticationHash);
if (!result.Succeeded)
{
_logger.LogWarning("Change KDF failed for user {userId}.", user.Id);
return result;
}
// Salt is ensured to be the same as authentication data, and the value stored in the account, and is not updated.
// Kdf - These will be seperated in the future, but for now are ensured to be the same as authentication data above.
user.Key = unlockData.MasterKeyWrappedUserKey;
user.Kdf = unlockData.Kdf.KdfType;
user.KdfIterations = unlockData.Kdf.Iterations;
user.KdfMemory = unlockData.Kdf.Memory;
user.KdfParallelism = unlockData.Kdf.Parallelism;
var now = DateTime.UtcNow;
user.RevisionDate = user.AccountRevisionDate = now;
user.LastKdfChangeDate = now;
await _userRepository.ReplaceAsync(user);
await _pushService.PushLogOutAsync(user.Id);
return IdentityResult.Success;
}
}

View File

@@ -1,5 +1,7 @@
using Bit.Core.KeyManagement.Commands;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Kdf;
using Bit.Core.KeyManagement.Kdf.Implementations;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.KeyManagement;
@@ -15,5 +17,6 @@ public static class KeyManagementServiceCollectionExtensions
private static void AddKeyManagementCommands(this IServiceCollection services)
{
services.AddScoped<IRegenerateUserAsymmetricKeysCommand, RegenerateUserAsymmetricKeysCommand>();
services.AddScoped<IChangeKdfCommand, ChangeKdfCommand>();
}
}

View File

@@ -0,0 +1,38 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Core.KeyManagement.Models.Data;
public class KdfSettings
{
public required KdfType KdfType { get; init; }
public required int Iterations { get; init; }
public int? Memory { get; init; }
public int? Parallelism { get; init; }
public void ValidateUnchangedForUser(User user)
{
if (user.Kdf != KdfType || user.KdfIterations != Iterations || user.KdfMemory != Memory || user.KdfParallelism != Parallelism)
{
throw new ArgumentException("Invalid KDF settings.");
}
}
public override bool Equals(object? obj)
{
if (obj is not KdfSettings other)
{
return false;
}
return KdfType == other.KdfType &&
Iterations == other.Iterations &&
Memory == other.Memory &&
Parallelism == other.Parallelism;
}
public override int GetHashCode()
{
return HashCode.Combine(KdfType, Iterations, Memory, Parallelism);
}
}

View File

@@ -0,0 +1,18 @@
using Bit.Core.Entities;
namespace Bit.Core.KeyManagement.Models.Data;
public class MasterPasswordAuthenticationData
{
public required KdfSettings Kdf { get; init; }
public required string MasterPasswordAuthenticationHash { get; init; }
public required string Salt { get; init; }
public void ValidateSaltUnchangedForUser(User user)
{
if (user.GetMasterPasswordSalt() != Salt)
{
throw new ArgumentException("Invalid master password salt.");
}
}
}

View File

@@ -0,0 +1,34 @@
#nullable enable
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Core.KeyManagement.Models.Data;
public class MasterPasswordUnlockAndAuthenticationData
{
public KdfType KdfType { get; set; }
public int KdfIterations { get; set; }
public int? KdfMemory { get; set; }
public int? KdfParallelism { get; set; }
public required string Email { get; set; }
public required string MasterKeyAuthenticationHash { get; set; }
public required string MasterKeyEncryptedUserKey { get; set; }
public string? MasterPasswordHint { get; set; }
public bool ValidateForUser(User user)
{
if (KdfType != user.Kdf || KdfMemory != user.KdfMemory || KdfParallelism != user.KdfParallelism || KdfIterations != user.KdfIterations)
{
return false;
}
else if (Email != user.Email)
{
return false;
}
else
{
return true;
}
}
}

View File

@@ -1,34 +1,20 @@
#nullable enable
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Core.KeyManagement.Models.Data;
public class MasterPasswordUnlockData
{
public KdfType KdfType { get; set; }
public int KdfIterations { get; set; }
public int? KdfMemory { get; set; }
public int? KdfParallelism { get; set; }
public required KdfSettings Kdf { get; init; }
public required string MasterKeyWrappedUserKey { get; init; }
public required string Salt { get; init; }
public required string Email { get; set; }
public required string MasterKeyAuthenticationHash { get; set; }
public required string MasterKeyEncryptedUserKey { get; set; }
public string? MasterPasswordHint { get; set; }
public bool ValidateForUser(User user)
public void ValidateSaltUnchangedForUser(User user)
{
if (KdfType != user.Kdf || KdfMemory != user.KdfMemory || KdfParallelism != user.KdfParallelism || KdfIterations != user.KdfIterations)
if (user.GetMasterPasswordSalt() != Salt)
{
return false;
}
else if (Email != user.Email)
{
return false;
}
else
{
return true;
throw new ArgumentException("Invalid master password salt.");
}
}
}

View File

@@ -19,7 +19,7 @@ public class RotateUserAccountKeysData
public string AccountPublicKey { get; set; }
// All methods to get to the userkey
public MasterPasswordUnlockData MasterPasswordUnlockData { get; set; }
public MasterPasswordUnlockAndAuthenticationData MasterPasswordUnlockData { get; set; }
public IEnumerable<EmergencyAccess> EmergencyAccesses { get; set; }
public IReadOnlyList<OrganizationUser> OrganizationUsers { get; set; }
public IEnumerable<WebAuthnLoginRotateKeyData> WebAuthnKeys { get; set; }

View File

@@ -15,7 +15,6 @@ public class OrganizationInvitesInfo
bool orgSsoLoginRequiredPolicyEnabled,
IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> orgUserTokenPairs,
Dictionary<Guid, bool> orgUserHasExistingUserDict,
bool isSubjectFeatureEnabled = false,
bool initOrganization = false
)
{
@@ -30,8 +29,6 @@ public class OrganizationInvitesInfo
OrgUserTokenPairs = orgUserTokenPairs;
OrgUserHasExistingUserDict = orgUserHasExistingUserDict;
IsSubjectFeatureEnabled = isSubjectFeatureEnabled;
}
public string OrganizationName { get; }
@@ -40,9 +37,6 @@ public class OrganizationInvitesInfo
public bool OrgSsoEnabled { get; }
public string OrgSsoIdentifier { get; }
public bool OrgSsoLoginRequiredPolicyEnabled { get; }
public bool IsSubjectFeatureEnabled { get; }
public IEnumerable<(OrganizationUser OrgUser, ExpiringToken Token)> OrgUserTokenPairs { get; }
public Dictionary<Guid, bool> OrgUserHasExistingUserDict { get; }

View File

@@ -20,40 +20,6 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel
OrganizationUser orgUser,
ExpiringToken expiringToken,
GlobalSettings globalSettings)
{
var freeOrgTitle = "A Bitwarden member invited you to an organization. Join now to start securing your passwords!";
return new OrganizationUserInvitedViewModel
{
TitleFirst = orgInvitesInfo.IsFreeOrg ? freeOrgTitle : "Join ",
TitleSecondBold =
orgInvitesInfo.IsFreeOrg
? string.Empty
: CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false),
TitleThird = orgInvitesInfo.IsFreeOrg ? string.Empty : " on Bitwarden and start securing your passwords!",
OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false) + orgUser.Status,
Email = WebUtility.UrlEncode(orgUser.Email),
OrganizationId = orgUser.OrganizationId.ToString(),
OrganizationUserId = orgUser.Id.ToString(),
Token = WebUtility.UrlEncode(expiringToken.Token),
ExpirationDate =
$"{expiringToken.ExpirationDate.ToLongDateString()} {expiringToken.ExpirationDate.ToShortTimeString()} UTC",
OrganizationNameUrlEncoded = WebUtility.UrlEncode(orgInvitesInfo.OrganizationName),
WebVaultUrl = globalSettings.BaseServiceUri.VaultWithHash,
SiteName = globalSettings.SiteName,
InitOrganization = orgInvitesInfo.InitOrganization,
OrgSsoIdentifier = orgInvitesInfo.OrgSsoIdentifier,
OrgSsoEnabled = orgInvitesInfo.OrgSsoEnabled,
OrgSsoLoginRequiredPolicyEnabled = orgInvitesInfo.OrgSsoLoginRequiredPolicyEnabled,
OrgUserHasExistingUser = orgInvitesInfo.OrgUserHasExistingUserDict[orgUser.Id]
};
}
public static OrganizationUserInvitedViewModel CreateFromInviteInfo_v2(
OrganizationInvitesInfo orgInvitesInfo,
OrganizationUser orgUser,
ExpiringToken expiringToken,
GlobalSettings globalSettings)
{
const string freeOrgTitle = "A Bitwarden member invited you to an organization. " +
"Join now to start securing your passwords!";

View File

@@ -13,7 +13,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
@@ -132,12 +132,10 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<IRevokeOrganizationUserCommand, RevokeOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserCommand, UpdateOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>();
services.AddScoped<IDeleteClaimedOrganizationUserAccountCommand, DeleteClaimedOrganizationUserAccountCommand>();
services.AddScoped<IConfirmOrganizationUserCommand, ConfirmOrganizationUserCommand>();
// vNext implementations (feature flagged)
services.AddScoped<IDeleteClaimedOrganizationUserAccountCommandvNext, DeleteClaimedOrganizationUserAccountCommandvNext>();
services.AddScoped<IDeleteClaimedOrganizationUserAccountValidatorvNext, DeleteClaimedOrganizationUserAccountValidatorvNext>();
services.AddScoped<IDeleteClaimedOrganizationUserAccountCommand, DeleteClaimedOrganizationUserAccountCommand>();
services.AddScoped<IDeleteClaimedOrganizationUserAccountValidator, DeleteClaimedOrganizationUserAccountValidator>();
}
private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services)

View File

@@ -63,5 +63,12 @@ public interface ICollectionRepository : IRepository<Collection, Guid>
Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable<Guid> collectionIds,
IEnumerable<CollectionAccessSelection> users, IEnumerable<CollectionAccessSelection> groups);
Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> affectedOrgUserIds, string defaultCollectionName);
/// <summary>
/// Creates default user collections for the specified organization users if they do not already have one.
/// </summary>
/// <param name="organizationId">The Organization ID.</param>
/// <param name="organizationUserIds">The Organization User IDs to create default collections for.</param>
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
/// <returns></returns>
Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName);
}

View File

@@ -38,8 +38,6 @@ public interface IUserService
Task<IdentityResult> ConvertToKeyConnectorAsync(User user);
Task<IdentityResult> AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key);
Task<IdentityResult> UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint);
Task<IdentityResult> ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, string key,
KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism);
Task<IdentityResult> RefreshSecurityStampAsync(User user, string masterPasswordHash);
Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true);
Task DisableTwoFactorProviderAsync(User user, TwoFactorProviderType type);

View File

@@ -355,11 +355,8 @@ public class HandlebarsMailService : IMailService
{
Debug.Assert(orgUserTokenPair.OrgUser.Email is not null);
var orgUserInviteViewModel = orgInvitesInfo.IsSubjectFeatureEnabled
? OrganizationUserInvitedViewModel.CreateFromInviteInfo_v2(
orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings)
: OrganizationUserInvitedViewModel.CreateFromInviteInfo(orgInvitesInfo, orgUserTokenPair.OrgUser,
orgUserTokenPair.Token, _globalSettings);
var orgUserInviteViewModel = OrganizationUserInvitedViewModel.CreateFromInviteInfo(orgInvitesInfo, orgUserTokenPair.OrgUser,
orgUserTokenPair.Token, _globalSettings);
return CreateMessage(orgUserTokenPair.OrgUser.Email, orgUserInviteViewModel);
});
@@ -369,20 +366,15 @@ public class HandlebarsMailService : IMailService
MailQueueMessage CreateMessage(string email, OrganizationUserInvitedViewModel model)
{
var subject = $"Join {model.OrganizationName}";
ArgumentNullException.ThrowIfNull(model);
if (orgInvitesInfo.IsSubjectFeatureEnabled)
var subject = model! switch
{
ArgumentNullException.ThrowIfNull(model);
subject = model! switch
{
{ IsFreeOrg: true, OrgUserHasExistingUser: true } => "You have been invited to a Bitwarden Organization",
{ IsFreeOrg: true, OrgUserHasExistingUser: false } => "You have been invited to Bitwarden Password Manager",
{ IsFreeOrg: false, OrgUserHasExistingUser: true } => $"{model.OrganizationName} invited you to their Bitwarden organization",
{ IsFreeOrg: false, OrgUserHasExistingUser: false } => $"{model.OrganizationName} set up a Bitwarden account for you"
};
}
{ IsFreeOrg: true, OrgUserHasExistingUser: true } => "You have been invited to a Bitwarden Organization",
{ IsFreeOrg: true, OrgUserHasExistingUser: false } => "You have been invited to Bitwarden Password Manager",
{ IsFreeOrg: false, OrgUserHasExistingUser: true } => $"{model.OrganizationName} invited you to their Bitwarden organization",
{ IsFreeOrg: false, OrgUserHasExistingUser: false } => $"{model.OrganizationName} set up a Bitwarden account for you"
};
var message = CreateDefaultMessage(subject, email);

View File

@@ -777,39 +777,6 @@ public class UserService : UserManager<User>, IUserService
return IdentityResult.Success;
}
public async Task<IdentityResult> ChangeKdfAsync(User user, string masterPassword, string newMasterPassword,
string key, KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism)
{
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
if (await CheckPasswordAsync(user, masterPassword))
{
var result = await UpdatePasswordHash(user, newMasterPassword);
if (!result.Succeeded)
{
return result;
}
var now = DateTime.UtcNow;
user.RevisionDate = user.AccountRevisionDate = now;
user.LastKdfChangeDate = now;
user.Key = key;
user.Kdf = kdf;
user.KdfIterations = kdfIterations;
user.KdfMemory = kdfMemory;
user.KdfParallelism = kdfParallelism;
await _userRepository.ReplaceAsync(user);
await _pushService.PushLogOutAsync(user.Id);
return IdentityResult.Success;
}
Logger.LogWarning("Change KDF failed for user {userId}.", user.Id);
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
}
public async Task<IdentityResult> RefreshSecurityStampAsync(User user, string secret)
{
if (user == null)

View File

@@ -92,6 +92,10 @@ public class GlobalSettings : IGlobalSettings
public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5;
public virtual bool EnableEmailVerification { get; set; }
public virtual string KdfDefaultHashKey { get; set; }
/// <summary>
/// This Hash Key is used to prevent enumeration attacks against the Send Access feature.
/// </summary>
public virtual string SendDefaultHashKey { get; set; }
public virtual string PricingUri { get; set; }
public string BuildExternalUri(string explicitValue, string name)
@@ -469,17 +473,34 @@ public class GlobalSettings : IGlobalSettings
public string CosmosConnectionString { get; set; }
public string LicenseKey { get; set; } = "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzM0NTY2NDAwLCJleHAiOjE3NjQ5NzkyMDAsImNvbXBhbnlfbmFtZSI6IkJpdHdhcmRlbiBJbmMuIiwiY29udGFjdF9pbmZvIjoiY29udGFjdEBkdWVuZGVzb2Z0d2FyZS5jb20iLCJlZGl0aW9uIjoiU3RhcnRlciIsImlkIjoiNjg3OCIsImZlYXR1cmUiOlsiaXN2IiwidW5saW1pdGVkX2NsaWVudHMiXSwicHJvZHVjdCI6IkJpdHdhcmRlbiJ9.TYc88W_t2t0F2AJV3rdyKwGyQKrKFriSAzm1tWFNHNR9QizfC-8bliGdT4Wgeie-ynCXs9wWaF-sKC5emg--qS7oe2iIt67Qd88WS53AwgTvAddQRA4NhGB1R7VM8GAikLieSos-DzzwLYRgjZdmcsprItYGSJuY73r-7-F97ta915majBytVxGF966tT9zF1aYk0bA8FS6DcDYkr5f7Nsy8daS_uIUAgNa_agKXtmQPqKujqtUb6rgWEpSp4OcQcG-8Dpd5jHqoIjouGvY-5LTgk5WmLxi_m-1QISjxUJrUm-UGao3_VwV5KFGqYrz8csdTl-HS40ihWcsWnrV0ug";
/// <summary>
/// Global override for sliding refresh token lifetime in seconds. If null, uses the constructor parameter value.
/// Sliding lifetime of a refresh token in seconds.
///
/// Each time the refresh token is used before the sliding window ends, its lifetime is extended by another SlidingRefreshTokenLifetimeSeconds.
///
/// If AbsoluteRefreshTokenLifetimeSeconds > 0, the sliding extensions are bounded by the absolute maximum lifetime.
/// If SlidingRefreshTokenLifetimeSeconds = 0, sliding mode is invalid (refresh tokens cannot be used).
/// </summary>
public int? SlidingRefreshTokenLifetimeSeconds { get; set; }
/// <summary>
/// Global override for absolute refresh token lifetime in seconds. If null, uses the constructor parameter value.
/// Maximum lifetime of a refresh token in seconds.
///
/// Token cannot be refreshed by any means beyond the absolute refresh expiration.
///
/// When setting this value to 0, the following effect applies:
/// If ApplyAbsoluteExpirationOnRefreshToken is set to true, the behavior is the same as when no refresh tokens are used.
/// If ApplyAbsoluteExpirationOnRefreshToken is set to false, refresh tokens only expire after the SlidingRefreshTokenLifetimeSeconds has passed.
/// </summary>
public int? AbsoluteRefreshTokenLifetimeSeconds { get; set; }
/// <summary>
/// Global override for refresh token expiration policy. False = Sliding (default), True = Absolute.
/// Controls whether refresh tokens expire absolutely or on a sliding window basis.
///
/// Absolute:
/// Token expires at a fixed point in time (defined by AbsoluteRefreshTokenLifetimeSeconds). Usage does not extend lifetime.
///
/// Sliding(default):
/// Token lifetime is renewed on each use, by the amount in SlidingRefreshTokenLifetimeSeconds. Extensions stop once AbsoluteRefreshTokenLifetimeSeconds is reached (if set > 0).
/// </summary>
public bool UseAbsoluteRefreshTokenExpiration { get; set; } = false;
public bool ApplyAbsoluteExpirationOnRefreshToken { get; set; } = false;
}
public class DataProtectionSettings

View File

@@ -41,9 +41,12 @@ public static class CoreHelpers
};
/// <summary>
/// Generate sequential Guid for Sql Server.
/// ref: https://github.com/nhibernate/nhibernate-core/blob/master/src/NHibernate/Id/GuidCombGenerator.cs
/// Generate a sequential Guid for Sql Server. This prevents SQL Server index fragmentation by incorporating timestamp
/// information for sequential ordering. This should be preferred to <see cref="Guid.NewGuid"/> for any database IDs.
/// </summary>
/// <remarks>
/// ref: https://github.com/nhibernate/nhibernate-core/blob/master/src/NHibernate/Id/GuidCombGenerator.cs
/// </remarks>
/// <returns>A comb Guid.</returns>
public static Guid GenerateComb()
=> GenerateComb(Guid.NewGuid(), DateTime.UtcNow);

View File

@@ -0,0 +1,36 @@
using System.Text;
namespace Bit.Core.Utilities;
public static class EnumerationProtectionHelpers
{
/// <summary>
/// Use this method to get a consistent int result based on the inputString that is in the range.
/// The same inputString will always return the same index result based on range input.
/// </summary>
/// <param name="hmacKey">Key used to derive the HMAC hash. Use a different key for each usage for optimal security</param>
/// <param name="inputString">The string to derive an index result</param>
/// <param name="range">The range of possible index values</param>
/// <returns>An int between 0 and range - 1</returns>
public static int GetIndexForInputHash(byte[] hmacKey, string inputString, int range)
{
if (hmacKey == null || range <= 0 || hmacKey.Length == 0)
{
return 0;
}
else
{
// Compute the HMAC hash of the salt
var hmacMessage = Encoding.UTF8.GetBytes(inputString.Trim().ToLowerInvariant());
using var hmac = new System.Security.Cryptography.HMACSHA256(hmacKey);
var hmacHash = hmac.ComputeHash(hmacMessage);
// Convert the hash to a number
var hashHex = BitConverter.ToString(hmacHash).Replace("-", string.Empty).ToLowerInvariant();
var hashFirst8Bytes = hashHex[..16];
var hashNumber = long.Parse(hashFirst8Bytes, System.Globalization.NumberStyles.HexNumber);
// Find the default KDF value for this hash number
var hashIndex = (int)(Math.Abs(hashNumber) % range);
return hashIndex;
}
}
}

View File

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Data;
namespace Bit.Core.Utilities;
@@ -34,4 +35,9 @@ public static class KdfSettingsValidator
break;
}
}
public static IEnumerable<ValidationResult> Validate(KdfSettings settings)
{
return Validate(settings.KdfType, settings.Iterations, settings.Memory, settings.Parallelism);
}
}

View File

@@ -20,7 +20,7 @@ public class ApiClient : Client
AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode, WebAuthnGrantValidator.GrantType };
// Use global setting: false = Sliding (default), true = Absolute
RefreshTokenExpiration = globalSettings.IdentityServer.UseAbsoluteRefreshTokenExpiration
RefreshTokenExpiration = globalSettings.IdentityServer.ApplyAbsoluteExpirationOnRefreshToken
? TokenExpiration.Absolute
: TokenExpiration.Sliding;

View File

@@ -34,18 +34,18 @@ public static class SendAccessConstants
public const string Otp = "otp";
}
public static class GrantValidatorResults
public static class SendIdGuidValidatorResults
{
/// <summary>
/// The sendId is valid and the request is well formed. Not returned in any response.
/// The <see cref="TokenRequest.SendId"/> in the request is a valid GUID and the request is well formed. Not returned in any response.
/// </summary>
public const string ValidSendGuid = "valid_send_guid";
/// <summary>
/// The sendId is missing from the request.
/// The <see cref="TokenRequest.SendId"/> is missing from the request.
/// </summary>
public const string SendIdRequired = "send_id_required";
/// <summary>
/// The sendId is invalid, does not match a known send.
/// The <see cref="TokenRequest.SendId"/> is invalid, does not match a known send.
/// </summary>
public const string InvalidSendId = "send_id_invalid";
}
@@ -53,11 +53,11 @@ public static class SendAccessConstants
public static class PasswordValidatorResults
{
/// <summary>
/// The passwordHashB64 does not match the send's password hash.
/// The <see cref="TokenRequest.ClientB64HashedPassword"/> does not match the send's password hash.
/// </summary>
public const string RequestPasswordDoesNotMatch = "password_hash_b64_invalid";
/// <summary>
/// The passwordHashB64 is missing from the request.
/// The <see cref="TokenRequest.ClientB64HashedPassword"/> is missing from the request.
/// </summary>
public const string RequestPasswordIsRequired = "password_hash_b64_required";
}
@@ -105,4 +105,14 @@ public static class SendAccessConstants
{
public const string Subject = "Your Bitwarden Send verification code is {0}";
}
/// <summary>
/// We use these static strings to help guide the enumeration protection logic.
/// </summary>
public static class EnumerationProtection
{
public const string Guid = "guid";
public const string Password = "password";
public const string Email = "email";
}
}

View File

@@ -13,21 +13,19 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
public class SendAccessGrantValidator(
ISendAuthenticationQuery _sendAuthenticationQuery,
ISendAuthenticationMethodValidator<NeverAuthenticate> _sendNeverAuthenticateValidator,
ISendAuthenticationMethodValidator<ResourcePassword> _sendPasswordRequestValidator,
ISendAuthenticationMethodValidator<EmailOtp> _sendEmailOtpRequestValidator,
IFeatureService _featureService)
: IExtensionGrantValidator
IFeatureService _featureService) : IExtensionGrantValidator
{
string IExtensionGrantValidator.GrantType => CustomGrantTypes.SendAccess;
private static readonly Dictionary<string, string>
_sendGrantValidatorErrorDescriptions = new()
private static readonly Dictionary<string, string> _sendGrantValidatorErrorDescriptions = new()
{
{ SendAccessConstants.GrantValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." },
{ SendAccessConstants.GrantValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." }
{ SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." },
{ SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." }
};
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
// Check the feature flag
@@ -38,7 +36,7 @@ public class SendAccessGrantValidator(
}
var (sendIdGuid, result) = GetRequestSendId(context);
if (result != SendAccessConstants.GrantValidatorResults.ValidSendGuid)
if (result != SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid)
{
context.Result = BuildErrorResult(result);
return;
@@ -49,15 +47,10 @@ public class SendAccessGrantValidator(
switch (method)
{
case NeverAuthenticate:
case NeverAuthenticate never:
// null send scenario.
// TODO PM-22675: Add send enumeration protection here (primarily benefits self hosted instances).
// We should only map to password or email + OTP protected.
// If user submits password guess for a falsely protected send, then we will return invalid password.
// If user submits email + OTP guess for a falsely protected send, then we will return email sent, do not actually send an email.
context.Result = BuildErrorResult(SendAccessConstants.GrantValidatorResults.InvalidSendId);
context.Result = await _sendNeverAuthenticateValidator.ValidateRequestAsync(context, never, sendIdGuid);
return;
case NotAuthenticated:
// automatically issue access token
context.Result = BuildBaseSuccessResult(sendIdGuid);
@@ -90,7 +83,7 @@ public class SendAccessGrantValidator(
// if the sendId is null then the request is the wrong shape and the request is invalid
if (sendId == null)
{
return (Guid.Empty, SendAccessConstants.GrantValidatorResults.SendIdRequired);
return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired);
}
// the send_id is not null so the request is the correct shape, so we will attempt to parse it
try
@@ -100,20 +93,20 @@ public class SendAccessGrantValidator(
// Guid.Empty indicates an invalid send_id return invalid grant
if (sendGuid == Guid.Empty)
{
return (Guid.Empty, SendAccessConstants.GrantValidatorResults.InvalidSendId);
return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId);
}
return (sendGuid, SendAccessConstants.GrantValidatorResults.ValidSendGuid);
return (sendGuid, SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid);
}
catch
{
return (Guid.Empty, SendAccessConstants.GrantValidatorResults.InvalidSendId);
return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId);
}
}
/// <summary>
/// Builds an error result for the specified error type.
/// </summary>
/// <param name="error">This error is a constant string from <see cref="SendAccessConstants.GrantValidatorResults"/></param>
/// <param name="error">This error is a constant string from <see cref="SendAccessConstants.SendIdGuidValidatorResults"/></param>
/// <returns>The error result.</returns>
private static GrantValidationResult BuildErrorResult(string error)
{
@@ -125,12 +118,12 @@ public class SendAccessGrantValidator(
return error switch
{
// Request is the wrong shape
SendAccessConstants.GrantValidatorResults.SendIdRequired => new GrantValidationResult(
SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired => new GrantValidationResult(
TokenRequestErrors.InvalidRequest,
errorDescription: _sendGrantValidatorErrorDescriptions[error],
customResponse),
// Request is correct shape but data is bad
SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult(
SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId => new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
errorDescription: _sendGrantValidatorErrorDescriptions[error],
customResponse),

View File

@@ -0,0 +1,87 @@
using System.Text;
using Bit.Core.Settings;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Utilities;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
/// <summary>
/// This class is used to protect our system from enumeration attacks. This Validator will always return an error result.
/// We hash the SendId Guid passed into the request to select the an error from the list of possible errors. This ensures
/// that the same error is always returned for the same SendId.
/// </summary>
/// <param name="globalSettings">We need access to a hash key to generate the error index.</param>
public class SendNeverAuthenticateRequestValidator(GlobalSettings globalSettings) : ISendAuthenticationMethodValidator<NeverAuthenticate>
{
private readonly string[] _errorOptions =
[
SendAccessConstants.EnumerationProtection.Guid,
SendAccessConstants.EnumerationProtection.Password,
SendAccessConstants.EnumerationProtection.Email
];
public Task<GrantValidationResult> ValidateRequestAsync(
ExtensionGrantValidationContext context,
NeverAuthenticate authMethod,
Guid sendId)
{
var neverAuthenticateError = GetErrorIndex(sendId, _errorOptions.Length);
var request = context.Request.Raw;
var errorType = neverAuthenticateError;
switch (neverAuthenticateError)
{
case SendAccessConstants.EnumerationProtection.Guid:
errorType = SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId;
break;
case SendAccessConstants.EnumerationProtection.Email:
var hasEmail = request.Get(SendAccessConstants.TokenRequest.Email) is not null;
errorType = hasEmail ? SendAccessConstants.EmailOtpValidatorResults.EmailInvalid
: SendAccessConstants.EmailOtpValidatorResults.EmailRequired;
break;
case SendAccessConstants.EnumerationProtection.Password:
var hasPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword) is not null;
errorType = hasPassword ? SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch
: SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired;
break;
}
return Task.FromResult(BuildErrorResult(errorType));
}
private static GrantValidationResult BuildErrorResult(string errorType)
{
// Create error response with custom response data
var customResponse = new Dictionary<string, object>
{
{ SendAccessConstants.SendAccessError, errorType }
};
var requestError = errorType switch
{
SendAccessConstants.EnumerationProtection.Guid => TokenRequestErrors.InvalidGrant,
SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired => TokenRequestErrors.InvalidGrant,
SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch => TokenRequestErrors.InvalidRequest,
SendAccessConstants.EmailOtpValidatorResults.EmailInvalid => TokenRequestErrors.InvalidGrant,
SendAccessConstants.EmailOtpValidatorResults.EmailRequired => TokenRequestErrors.InvalidRequest,
_ => TokenRequestErrors.InvalidGrant
};
return new GrantValidationResult(requestError, errorType, customResponse);
}
private string GetErrorIndex(Guid sendId, int range)
{
var salt = sendId.ToString();
byte[] hmacKey = [];
if (CoreHelpers.SettingHasValue(globalSettings.SendDefaultHashKey))
{
hmacKey = Encoding.UTF8.GetBytes(globalSettings.SendDefaultHashKey);
}
var index = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
return _errorOptions[index];
}
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.Auth.Entities;
using Bit.Core;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.Utilities;
@@ -7,6 +8,7 @@ using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Response;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Identity.Utilities;
@@ -24,6 +26,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
private readonly IDeviceRepository _deviceRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly ILoginApprovingClientTypes _loginApprovingClientTypes;
private readonly IFeatureService _featureService;
private UserDecryptionOptions _options = new UserDecryptionOptions();
private User _user = null!;
@@ -34,13 +37,15 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
ICurrentContext currentContext,
IDeviceRepository deviceRepository,
IOrganizationUserRepository organizationUserRepository,
ILoginApprovingClientTypes loginApprovingClientTypes
ILoginApprovingClientTypes loginApprovingClientTypes,
IFeatureService featureService
)
{
_currentContext = currentContext;
_deviceRepository = deviceRepository;
_organizationUserRepository = organizationUserRepository;
_loginApprovingClientTypes = loginApprovingClientTypes;
_featureService = featureService;
}
public IUserDecryptionOptionsBuilder ForUser(User user)
@@ -65,8 +70,10 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
{
if (credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled)
{
_options.WebAuthnPrfOption = new WebAuthnPrfDecryptionOption(credential.EncryptedPrivateKey, credential.EncryptedUserKey);
_options.WebAuthnPrfOption =
new WebAuthnPrfDecryptionOption(credential.EncryptedPrivateKey, credential.EncryptedUserKey);
}
return this;
}
@@ -74,7 +81,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
{
BuildMasterPasswordUnlock();
BuildKeyConnectorOptions();
await BuildTrustedDeviceOptions();
await BuildTrustedDeviceOptionsAsync();
return _options;
}
@@ -87,13 +94,14 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
}
var ssoConfigurationData = _ssoConfig.GetData();
if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl))
if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } &&
!string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl))
{
_options.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl);
}
}
private async Task BuildTrustedDeviceOptions()
private async Task BuildTrustedDeviceOptionsAsync()
{
// TrustedDeviceEncryption only exists for SSO, if that changes then these guards should change
if (_ssoConfig == null)
@@ -101,7 +109,8 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
return;
}
var isTdeActive = _ssoConfig.GetData() is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption };
var isTdeActive = _ssoConfig.GetData() is
{ MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption };
var isTdeOffboarding = !_user.HasMasterPassword() && _device != null && _device.IsTrusted() && !isTdeActive;
if (!isTdeActive && !isTdeOffboarding)
{
@@ -120,25 +129,51 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
if (_device != null)
{
var allDevices = await _deviceRepository.GetManyByUserIdAsync(_user.Id);
// Checks if the current user has any devices that are capable of approving login with device requests except for
// their current device.
// NOTE: this doesn't check for if the users have configured the devices to be capable of approving requests as that is a client side setting.
hasLoginApprovingDevice = allDevices.Any(d => d.Identifier != _device.Identifier && _loginApprovingClientTypes.TypesThatCanApprove.Contains(DeviceTypes.ToClientType(d.Type)));
// Checks if the current user has any devices that are capable of approving login with device requests
// except for their current device.
hasLoginApprovingDevice = allDevices.Any(d =>
d.Identifier != _device.Identifier &&
_loginApprovingClientTypes.TypesThatCanApprove.Contains(DeviceTypes.ToClientType(d.Type)));
}
// Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP
var hasManageResetPasswordPermission = false;
// when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here
if (_currentContext.Organizations != null && _currentContext.Organizations.Any(o => o.Id == _ssoConfig.OrganizationId))
{
// TDE requires single org so grabbing first org & id is fine.
hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId);
}
// If sso configuration data is not null then I know for sure that ssoConfiguration isn't null
// Just-in-time-provisioned users, which can include users invited to a TDE organization with SSO and granted
// the Admin/Owner role or Custom user role with ManageResetPassword permission, will not have claims available
// in context to reflect this permission if granted as part of an invite for the current organization.
// Therefore, as written today, CurrentContext will not surface those permissions for those users.
// In order to make this check accurate at first login for all applicable cases, we have to go back to the
// database record.
// In the TDE flow, the users will have been JIT-provisioned at SSO callback time, and the relationship between
// user and organization user will have been codified.
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id);
var hasManageResetPasswordPermission = false;
if (_featureService.IsEnabled(FeatureFlagKeys.PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword))
{
hasManageResetPasswordPermission = await EvaluateHasManageResetPasswordPermission();
}
else
{
// TODO: PM-26065 remove use of above feature flag from the server, and remove this branching logic, which
// has been replaced by EvaluateHasManageResetPasswordPermission.
// Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP.
// When removing feature flags, please also see notes and removals intended for test suite in
// Build_WhenManageResetPasswordPermissions_ShouldReturnHasManageResetPasswordPermissionTrue.
// when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here
if (_currentContext.Organizations != null && _currentContext.Organizations.Any(o => o.Id == _ssoConfig.OrganizationId))
{
// TDE requires single org so grabbing first org & id is fine.
hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId);
}
// If sso configuration data is not null then I know for sure that ssoConfiguration isn't null
// NOTE: Commented from original impl because the organization user repository call has been hoisted to support
// branching paths through flagging.
//organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id);
hasManageResetPasswordPermission |= organizationUser != null && (organizationUser.Type == OrganizationUserType.Owner || organizationUser.Type == OrganizationUserType.Admin);
}
hasManageResetPasswordPermission |= organizationUser != null && (organizationUser.Type == OrganizationUserType.Owner || organizationUser.Type == OrganizationUserType.Admin);
// They are only able to be approved by an admin if they have enrolled is reset password
var hasAdminApproval = organizationUser != null && !string.IsNullOrEmpty(organizationUser.ResetPasswordKey);
@@ -149,6 +184,31 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
isTdeOffboarding,
encryptedPrivateKey,
encryptedUserKey);
return;
async Task<bool> EvaluateHasManageResetPasswordPermission()
{
// PM-23174
// Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP
if (organizationUser == null)
{
return false;
}
var organizationUserHasResetPasswordPermission =
// The repository will pull users in all statuses, so we also need to ensure that revoked-status users do not have
// permissions sent down.
organizationUser.Status is OrganizationUserStatusType.Invited or OrganizationUserStatusType.Accepted or
OrganizationUserStatusType.Confirmed &&
// Admins and owners get ManageResetPassword functionally "for free" through their role.
(organizationUser.Type is OrganizationUserType.Admin or OrganizationUserType.Owner ||
// Custom users can have the ManagePasswordReset permission assigned directly.
organizationUser.GetPermissions() is { ManageResetPassword: true });
return organizationUserHasResetPasswordPermission ||
// A provider user for the given organization gets ManageResetPassword through that relationship.
await _currentContext.ProviderUserForOrgAsync(_ssoConfig.OrganizationId);
}
}
private void BuildMasterPasswordUnlock()

View File

@@ -29,6 +29,7 @@ public static class ServiceCollectionExtensions
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
services.AddTransient<ISendAuthenticationMethodValidator<ResourcePassword>, SendPasswordRequestValidator>();
services.AddTransient<ISendAuthenticationMethodValidator<EmailOtp>, SendEmailOtpRequestValidator>();
services.AddTransient<ISendAuthenticationMethodValidator<NeverAuthenticate>, SendNeverAuthenticateRequestValidator>();
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
var identityServerBuilder = services

View File

@@ -1,6 +1,7 @@
using System.Data;
using Bit.Core.Entities;
using Bit.Core.Vault.Entities;
using Bit.Infrastructure.Dapper.Utilities;
using Microsoft.Data.SqlClient;
namespace Bit.Infrastructure.Dapper.AdminConsole.Helpers;
@@ -8,11 +9,25 @@ namespace Bit.Infrastructure.Dapper.AdminConsole.Helpers;
public static class BulkResourceCreationService
{
private const string _defaultErrorMessage = "Must have at least one record for bulk creation.";
public static async Task CreateCollectionsUsersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable<CollectionUser> collectionUsers, string errorMessage = _defaultErrorMessage)
public static async Task CreateCollectionsUsersAsync(SqlConnection connection, SqlTransaction transaction,
IEnumerable<CollectionUser> collectionUsers, string errorMessage = _defaultErrorMessage)
{
// Offload some work from SQL Server by pre-sorting before insert.
// This lets us use the SqlBulkCopy.ColumnOrderHints to improve performance and reduce deadlocks.
var sortedCollectionUsers = collectionUsers
.OrderBySqlGuid(cu => cu.CollectionId)
.ThenBySqlGuid(cu => cu.OrganizationUserId)
.ToList();
using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction);
bulkCopy.DestinationTableName = "[dbo].[CollectionUser]";
var dataTable = BuildCollectionsUsersTable(bulkCopy, collectionUsers, errorMessage);
bulkCopy.BatchSize = 500;
bulkCopy.BulkCopyTimeout = 120;
bulkCopy.EnableStreaming = true;
bulkCopy.ColumnOrderHints.Add("CollectionId", SortOrder.Ascending);
bulkCopy.ColumnOrderHints.Add("OrganizationUserId", SortOrder.Ascending);
var dataTable = BuildCollectionsUsersTable(bulkCopy, sortedCollectionUsers, errorMessage);
await bulkCopy.WriteToServerAsync(dataTable);
}
@@ -96,11 +111,21 @@ public static class BulkResourceCreationService
return table;
}
public static async Task CreateCollectionsAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable<Collection> collections, string errorMessage = _defaultErrorMessage)
public static async Task CreateCollectionsAsync(SqlConnection connection, SqlTransaction transaction,
IEnumerable<Collection> collections, string errorMessage = _defaultErrorMessage)
{
// Offload some work from SQL Server by pre-sorting before insert.
// This lets us use the SqlBulkCopy.ColumnOrderHints to improve performance and reduce deadlocks.
var sortedCollections = collections.OrderBySqlGuid(c => c.Id).ToList();
using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction);
bulkCopy.DestinationTableName = "[dbo].[Collection]";
var dataTable = BuildCollectionsTable(bulkCopy, collections, errorMessage);
bulkCopy.BatchSize = 500;
bulkCopy.BulkCopyTimeout = 120;
bulkCopy.EnableStreaming = true;
bulkCopy.ColumnOrderHints.Add("Id", SortOrder.Ascending);
var dataTable = BuildCollectionsTable(bulkCopy, sortedCollections, errorMessage);
await bulkCopy.WriteToServerAsync(dataTable);
}

View File

@@ -6,6 +6,7 @@ using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Infrastructure.Dapper.AdminConsole.Helpers;
using Dapper;
using Microsoft.Data.SqlClient;
@@ -326,9 +327,10 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
}
}
public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> affectedOrgUserIds, string defaultCollectionName)
public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
{
if (!affectedOrgUserIds.Any())
organizationUserIds = organizationUserIds.ToList();
if (!organizationUserIds.Any())
{
return;
}
@@ -340,7 +342,7 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
{
var orgUserIdWithDefaultCollection = await GetOrgUserIdsWithDefaultCollectionAsync(connection, transaction, organizationId);
var missingDefaultCollectionUserIds = affectedOrgUserIds.Except(orgUserIdWithDefaultCollection);
var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection);
var (collectionUsers, collections) = BuildDefaultCollectionForUsers(organizationId, missingDefaultCollectionUserIds, defaultCollectionName);
@@ -393,7 +395,7 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
foreach (var orgUserId in missingDefaultCollectionUserIds)
{
var collectionId = Guid.NewGuid();
var collectionId = CoreHelpers.GenerateComb();
collections.Add(new Collection
{

View File

@@ -0,0 +1,26 @@
using System.Data.SqlTypes;
namespace Bit.Infrastructure.Dapper.Utilities;
public static class SqlGuidHelpers
{
/// <summary>
/// Sorts the source IEnumerable by the specified Guid property using the <see cref="SqlGuid"/> comparison logic.
/// This is required because MSSQL server compares (and therefore sorts) Guids differently to C#.
/// Ref: https://learn.microsoft.com/en-us/sql/connect/ado-net/sql/compare-guid-uniqueidentifier-values
/// </summary>
public static IOrderedEnumerable<T> OrderBySqlGuid<T>(
this IEnumerable<T> source,
Func<T, Guid> keySelector)
{
return source.OrderBy(x => new SqlGuid(keySelector(x)));
}
/// <inheritdoc cref="OrderBySqlGuid"/>
public static IOrderedEnumerable<T> ThenBySqlGuid<T>(
this IOrderedEnumerable<T> source,
Func<T, Guid> keySelector)
{
return source.ThenBy(x => new SqlGuid(keySelector(x)));
}
}

View File

@@ -2,6 +2,7 @@
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Infrastructure.EntityFramework.Models;
using Bit.Infrastructure.EntityFramework.Repositories.Queries;
using LinqToDB.EntityFrameworkCore;
@@ -793,9 +794,10 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
// SaveChangesAsync is expected to be called outside this method
}
public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> affectedOrgUserIds, string defaultCollectionName)
public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
{
if (!affectedOrgUserIds.Any())
organizationUserIds = organizationUserIds.ToList();
if (!organizationUserIds.Any())
{
return;
}
@@ -804,8 +806,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
var dbContext = GetDatabaseContext(scope);
var orgUserIdWithDefaultCollection = await GetOrgUserIdsWithDefaultCollectionAsync(dbContext, organizationId);
var missingDefaultCollectionUserIds = affectedOrgUserIds.Except(orgUserIdWithDefaultCollection);
var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection);
var (collectionUsers, collections) = BuildDefaultCollectionForUsers(organizationId, missingDefaultCollectionUserIds, defaultCollectionName);
@@ -850,7 +851,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
foreach (var orgUserId in missingDefaultCollectionUserIds)
{
var collectionId = Guid.NewGuid();
var collectionId = CoreHelpers.GenerateComb();
collections.Add(new Collection
{

View File

@@ -281,17 +281,20 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
{
var dbContext = GetDatabaseContext(scope);
var collectionCiphers = from cc in dbContext.CollectionCiphers
join c in dbContext.Collections
on cc.CollectionId equals c.Id
where c.OrganizationId == organizationId
select cc;
dbContext.RemoveRange(collectionCiphers);
var ciphersToDelete = from c in dbContext.Ciphers
where c.OrganizationId == organizationId
&& !c.CollectionCiphers.Any(cc =>
cc.Collection.Type == CollectionType.DefaultUserCollection)
select c;
dbContext.RemoveRange(ciphersToDelete);
var ciphers = from c in dbContext.Ciphers
where c.OrganizationId == organizationId
select c;
dbContext.RemoveRange(ciphers);
var collectionCiphersToRemove = from cc in dbContext.CollectionCiphers
join col in dbContext.Collections on cc.CollectionId equals col.Id
join c in dbContext.Ciphers on cc.CipherId equals c.Id
where col.Type != CollectionType.DefaultUserCollection
&& c.OrganizationId == organizationId
select cc;
dbContext.RemoveRange(collectionCiphersToRemove);
await OrganizationUpdateStorage(organizationId);
await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId);

View File

@@ -23,10 +23,6 @@ public class SourceFileLineOperationFilter : IOperationFilter
var (fileName, lineNumber) = GetSourceFileLine(context.MethodInfo);
if (fileName != null && lineNumber > 0)
{
// Add the information with a link to the source file at the end of the operation description
operation.Description +=
$"\nThis operation is defined on: [`https://github.com/bitwarden/server/blob/main/{fileName}#L{lineNumber}`]";
// Also add the information as extensions, so other tools can use it in the future
operation.Extensions.Add("x-source-file", new OpenApiString(fileName));
operation.Extensions.Add("x-source-line", new OpenApiInteger(lineNumber));

View File

@@ -19,9 +19,10 @@ public static class SwaggerGenOptionsExt
// Set the operation ID to the name of the controller followed by the name of the function.
// Note that the "Controller" suffix for the controllers, and the "Async" suffix for the actions
// are removed already, so we don't need to do that ourselves.
// TODO(Dani): This is disabled until we remove all the duplicate operation IDs.
// config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}");
// config.DocumentFilter<CheckDuplicateOperationIdsDocumentFilter>();
config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}");
// Because we're setting custom operation IDs, we need to ensure that we don't accidentally
// introduce duplicate IDs, which is against the OpenAPI specification and could lead to issues.
config.DocumentFilter<CheckDuplicateOperationIdsDocumentFilter>();
// These two filters require debug symbols/git, so only add them in development mode
if (environment.IsDevelopment())

View File

@@ -7,7 +7,7 @@ AS
SELECT
[AR].*,
[D].[Id] AS [DeviceId],
ROW_NUMBER() OVER (PARTITION BY [AR].[RequestDeviceIdentifier] ORDER BY [AR].[CreationDate] DESC) AS [rn]
ROW_NUMBER() OVER (PARTITION BY [AR].[RequestDeviceIdentifier], [AR].[UserId] ORDER BY [AR].[CreationDate] DESC) AS [rn]
FROM [dbo].[AuthRequest] [AR]
LEFT JOIN [dbo].[Device] [D]
ON [AR].[RequestDeviceIdentifier] = [D].[Identifier]

View File

@@ -1,49 +1,91 @@
CREATE PROCEDURE [dbo].[Cipher_DeleteByOrganizationId]
@OrganizationId AS UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON;
DECLARE @BatchSize INT = 100
DECLARE @BatchSize INT = 1000;
-- Delete collection ciphers
WHILE @BatchSize > 0
BEGIN
BEGIN TRANSACTION Cipher_DeleteByOrganizationId_CC
BEGIN TRY
BEGIN TRANSACTION;
DELETE TOP(@BatchSize) CC
FROM
[dbo].[CollectionCipher] CC
INNER JOIN
[dbo].[Collection] C ON C.[Id] = CC.[CollectionId]
WHERE
C.[OrganizationId] = @OrganizationId
---------------------------------------------------------------------
-- 1. Delete organization ciphers that are NOT in any default
-- user collection (Collection.Type = 1).
---------------------------------------------------------------------
WHILE 1 = 1
BEGIN
;WITH Target AS
(
SELECT TOP (@BatchSize) C.Id
FROM dbo.Cipher C
WHERE C.OrganizationId = @OrganizationId
AND NOT EXISTS (
SELECT 1
FROM dbo.CollectionCipher CC2
INNER JOIN dbo.Collection Col2
ON Col2.Id = CC2.CollectionId
AND Col2.Type = 1 -- Default user collection
WHERE CC2.CipherId = C.Id
)
ORDER BY C.Id -- Deterministic ordering (matches clustered index)
)
DELETE C
FROM dbo.Cipher C
INNER JOIN Target T ON T.Id = C.Id;
SET @BatchSize = @@ROWCOUNT
IF @@ROWCOUNT = 0 BREAK;
END
COMMIT TRANSACTION Cipher_DeleteByOrganizationId_CC
END
---------------------------------------------------------------------
-- 2. Remove remaining CollectionCipher rows that reference
-- non-default (Type = 0 / shared) collections, for ciphers
-- that were preserved because they belong to at least one
-- default (Type = 1) collection.
---------------------------------------------------------------------
SET @BatchSize = 1000;
WHILE 1 = 1
BEGIN
;WITH ToDelete AS
(
SELECT TOP (@BatchSize)
CC.CipherId,
CC.CollectionId
FROM dbo.CollectionCipher CC
INNER JOIN dbo.Collection Col
ON Col.Id = CC.CollectionId
AND Col.Type = 0 -- Non-default collections
INNER JOIN dbo.Cipher C
ON C.Id = CC.CipherId
WHERE C.OrganizationId = @OrganizationId
ORDER BY CC.CollectionId, CC.CipherId -- Matches clustered index
)
DELETE CC
FROM dbo.CollectionCipher CC
INNER JOIN ToDelete TD
ON CC.CipherId = TD.CipherId
AND CC.CollectionId = TD.CollectionId;
-- Reset batch size
SET @BatchSize = 100
IF @@ROWCOUNT = 0 BREAK;
END
-- Delete ciphers
WHILE @BatchSize > 0
BEGIN
BEGIN TRANSACTION Cipher_DeleteByOrganizationId
---------------------------------------------------------------------
-- 3. Bump revision date (inside transaction for consistency)
---------------------------------------------------------------------
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId;
DELETE TOP(@BatchSize)
FROM
[dbo].[Cipher]
WHERE
[OrganizationId] = @OrganizationId
COMMIT TRANSACTION ;
SET @BatchSize = @@ROWCOUNT
COMMIT TRANSACTION Cipher_DeleteByOrganizationId
END
-- Cleanup organization
EXEC [dbo].[Organization_UpdateStorage] @OrganizationId
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
END
---------------------------------------------------------------------
-- 4. Update storage usage (outside the transaction to avoid
-- holding locks during long-running calculation)
---------------------------------------------------------------------
EXEC [dbo].[Organization_UpdateStorage] @OrganizationId;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
THROW;
END CATCH
END
GO

View File

@@ -7,7 +7,7 @@ using Bit.Api.Models.Request;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
@@ -33,10 +33,6 @@ public class OrganizationUserControllerTests : IClassFixture<ApiApplicationFacto
featureService
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
.Returns(true);
featureService
.IsEnabled(FeatureFlagKeys.DeleteClaimedUserAccountRefactor)
.Returns(true);
});
_client = _factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);

View File

@@ -29,6 +29,7 @@ using Bit.Test.Common.AutoFixture.Attributes;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using NSubstitute;
using Xunit;
@@ -305,29 +306,14 @@ public class OrganizationUsersControllerTests
[Theory]
[BitAutoData]
public async Task DeleteAccount_WhenUserCanManageUsers_Success(
Guid orgId, Guid id, User currentUser, SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser);
await sutProvider.Sut.DeleteAccount(orgId, id);
await sutProvider.GetDependency<IDeleteClaimedOrganizationUserAccountCommand>()
.Received(1)
.DeleteUserAsync(orgId, id, currentUser.Id);
}
[Theory]
[BitAutoData]
public async Task DeleteAccount_WhenCurrentUserNotFound_ThrowsUnauthorizedAccessException(
public async Task DeleteAccount_WhenCurrentUserNotFound_ReturnsUnauthorizedResult(
Guid orgId, Guid id, SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs((User)null);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs((Guid?)null);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
sutProvider.Sut.DeleteAccount(orgId, id));
var result = await sutProvider.Sut.DeleteAccount(orgId, id);
Assert.IsType<UnauthorizedHttpResult>(result);
}
[Theory]

View File

@@ -10,6 +10,7 @@ using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Kdf;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -33,6 +34,7 @@ public class AccountsControllerTests : IDisposable
private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand;
private readonly IFeatureService _featureService;
private readonly ITwoFactorEmailService _twoFactorEmailService;
private readonly IChangeKdfCommand _changeKdfCommand;
public AccountsControllerTests()
@@ -47,7 +49,7 @@ public class AccountsControllerTests : IDisposable
_tdeOffboardingPasswordCommand = Substitute.For<ITdeOffboardingPasswordCommand>();
_featureService = Substitute.For<IFeatureService>();
_twoFactorEmailService = Substitute.For<ITwoFactorEmailService>();
_changeKdfCommand = Substitute.For<IChangeKdfCommand>();
_sut = new AccountsController(
_organizationService,
@@ -59,7 +61,8 @@ public class AccountsControllerTests : IDisposable
_tdeOffboardingPasswordCommand,
_twoFactorIsEnabledQuery,
_featureService,
_twoFactorEmailService
_twoFactorEmailService,
_changeKdfCommand
);
}
@@ -242,12 +245,18 @@ public class AccountsControllerTests : IDisposable
{
var user = GenerateExampleUser();
ConfigureUserServiceToReturnValidPrincipalFor(user);
_userService.ChangePasswordAsync(user, default, default, default, default)
_userService.ChangePasswordAsync(user, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(Task.FromResult(IdentityResult.Success));
await _sut.PostPassword(new PasswordRequestModel());
await _sut.PostPassword(new PasswordRequestModel
{
MasterPasswordHash = "masterPasswordHash",
NewMasterPasswordHash = "newMasterPasswordHash",
MasterPasswordHint = "masterPasswordHint",
Key = "key"
});
await _userService.Received(1).ChangePasswordAsync(user, default, default, default, default);
await _userService.Received(1).ChangePasswordAsync(user, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
@@ -256,7 +265,13 @@ public class AccountsControllerTests : IDisposable
ConfigureUserServiceToReturnNullPrincipal();
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => _sut.PostPassword(new PasswordRequestModel())
() => _sut.PostPassword(new PasswordRequestModel
{
MasterPasswordHash = "masterPasswordHash",
NewMasterPasswordHash = "newMasterPasswordHash",
MasterPasswordHint = "masterPasswordHint",
Key = "key"
})
);
}
@@ -265,11 +280,17 @@ public class AccountsControllerTests : IDisposable
{
var user = GenerateExampleUser();
ConfigureUserServiceToReturnValidPrincipalFor(user);
_userService.ChangePasswordAsync(user, default, default, default, default)
_userService.ChangePasswordAsync(user, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(Task.FromResult(IdentityResult.Failed()));
await Assert.ThrowsAsync<BadRequestException>(
() => _sut.PostPassword(new PasswordRequestModel())
() => _sut.PostPassword(new PasswordRequestModel
{
MasterPasswordHash = "masterPasswordHash",
NewMasterPasswordHash = "newMasterPasswordHash",
MasterPasswordHint = "masterPasswordHint",
Key = "key"
})
);
}
@@ -593,6 +614,30 @@ public class AccountsControllerTests : IDisposable
await _twoFactorEmailService.Received(1).SendNewDeviceVerificationEmailAsync(user);
}
[Theory]
[BitAutoData]
public async Task PostKdf_WithNullAuthenticationData_ShouldFail(
User user, PasswordRequestModel model)
{
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
model.AuthenticationData = null;
// Act
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostKdf(model));
}
[Theory]
[BitAutoData]
public async Task PostKdf_WithNullUnlockData_ShouldFail(
User user, PasswordRequestModel model)
{
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
model.UnlockData = null;
// Act
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostKdf(model));
}
// Below are helper functions that currently belong to this
// test class, but ultimately may need to be split out into
// something greater in order to share common test steps with

View File

@@ -222,7 +222,7 @@ public class AuthRequestsControllerTests
}
[Theory, BitAutoData]
public async Task Put_ReturnsAuthRequest(
public async Task Put_WithRequestNotApproved_ReturnsAuthRequest(
SutProvider<AuthRequestsController> sutProvider,
User user,
AuthRequestUpdateRequestModel requestModel,
@@ -230,6 +230,7 @@ public class AuthRequestsControllerTests
{
// Arrange
SetBaseServiceUri(sutProvider);
requestModel.RequestApproved = false; // Not an approval, so validation should be skipped
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
@@ -248,6 +249,117 @@ public class AuthRequestsControllerTests
Assert.IsType<AuthRequestResponseModel>(result);
}
[Theory, BitAutoData]
public async Task Put_WithApprovedRequest_ValidatesAndReturnsAuthRequest(
SutProvider<AuthRequestsController> sutProvider,
User user,
AuthRequestUpdateRequestModel requestModel,
AuthRequest currentAuthRequest,
AuthRequest updatedAuthRequest,
List<PendingAuthRequestDetails> pendingRequests)
{
// Arrange
SetBaseServiceUri(sutProvider);
requestModel.RequestApproved = true; // Approval triggers validation
currentAuthRequest.RequestDeviceIdentifier = "device-identifier-123";
// Setup pending requests - make the current request the most recent for its device
var mostRecentForDevice = new PendingAuthRequestDetails(currentAuthRequest, Guid.NewGuid());
pendingRequests.Add(mostRecentForDevice);
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
.Returns(user.Id);
// Setup validation dependencies
sutProvider.GetDependency<IAuthRequestService>()
.GetAuthRequestAsync(currentAuthRequest.Id, user.Id)
.Returns(currentAuthRequest);
sutProvider.GetDependency<IAuthRequestRepository>()
.GetManyPendingAuthRequestByUserId(user.Id)
.Returns(pendingRequests);
sutProvider.GetDependency<IAuthRequestService>()
.UpdateAuthRequestAsync(currentAuthRequest.Id, user.Id, requestModel)
.Returns(updatedAuthRequest);
// Act
var result = await sutProvider.Sut
.Put(currentAuthRequest.Id, requestModel);
// Assert
Assert.NotNull(result);
Assert.IsType<AuthRequestResponseModel>(result);
}
[Theory, BitAutoData]
public async Task Put_WithApprovedRequest_CurrentAuthRequestNotFound_ThrowsNotFoundException(
SutProvider<AuthRequestsController> sutProvider,
User user,
AuthRequestUpdateRequestModel requestModel,
Guid authRequestId)
{
// Arrange
requestModel.RequestApproved = true; // Approval triggers validation
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
.Returns(user.Id);
// Current auth request not found
sutProvider.GetDependency<IAuthRequestService>()
.GetAuthRequestAsync(authRequestId, user.Id)
.Returns((AuthRequest)null);
// Act & Assert
var exception = await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.Put(authRequestId, requestModel));
}
[Theory, BitAutoData]
public async Task Put_WithApprovedRequest_NotMostRecentForDevice_ThrowsBadRequestException(
SutProvider<AuthRequestsController> sutProvider,
User user,
AuthRequestUpdateRequestModel requestModel,
AuthRequest currentAuthRequest,
List<PendingAuthRequestDetails> pendingRequests)
{
// Arrange
requestModel.RequestApproved = true; // Approval triggers validation
currentAuthRequest.RequestDeviceIdentifier = "device-identifier-123";
// Setup pending requests - make a different request the most recent for the same device
var differentAuthRequest = new AuthRequest
{
Id = Guid.NewGuid(), // Different ID than current request
RequestDeviceIdentifier = currentAuthRequest.RequestDeviceIdentifier,
UserId = user.Id,
Type = AuthRequestType.AuthenticateAndUnlock,
CreationDate = DateTime.UtcNow
};
var mostRecentForDevice = new PendingAuthRequestDetails(differentAuthRequest, Guid.NewGuid());
pendingRequests.Add(mostRecentForDevice);
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
.Returns(user.Id);
sutProvider.GetDependency<IAuthRequestService>()
.GetAuthRequestAsync(currentAuthRequest.Id, user.Id)
.Returns(currentAuthRequest);
sutProvider.GetDependency<IAuthRequestRepository>()
.GetManyPendingAuthRequestByUserId(user.Id)
.Returns(pendingRequests);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.Put(currentAuthRequest.Id, requestModel));
Assert.Equal("This request is no longer valid. Make sure to approve the most recent request.", exception.Message);
}
private void SetBaseServiceUri(SutProvider<AuthRequestsController> sutProvider)
{
sutProvider.GetDependency<IGlobalSettings>()

View File

@@ -18,7 +18,7 @@ public class MasterPasswordUnlockDataModelTests
[InlineData(KdfType.Argon2id, 3, 64, 4)]
public void Validate_Success(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism)
{
var model = new MasterPasswordUnlockDataModel
var model = new MasterPasswordUnlockAndAuthenticationDataModel
{
KdfType = kdfType,
KdfIterations = kdfIterations,
@@ -43,7 +43,7 @@ public class MasterPasswordUnlockDataModelTests
[InlineData((KdfType)2, 2, 64, 4)]
public void Validate_Failure(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism)
{
var model = new MasterPasswordUnlockDataModel
var model = new MasterPasswordUnlockAndAuthenticationDataModel
{
KdfType = kdfType,
KdfIterations = kdfIterations,
@@ -59,7 +59,7 @@ public class MasterPasswordUnlockDataModelTests
Assert.NotNull(result.First().ErrorMessage);
}
private static List<ValidationResult> Validate(MasterPasswordUnlockDataModel model)
private static List<ValidationResult> Validate(MasterPasswordUnlockAndAuthenticationDataModel model)
{
var results = new List<ValidationResult>();
Validator.TryValidateObject(model, new ValidationContext(model), results, true);

View File

@@ -1,65 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Core.Enums;
using Xunit;
namespace Bit.Api.Test.Models.Request.Accounts;
public class KdfRequestModelTests
{
[Theory]
[InlineData(KdfType.PBKDF2_SHA256, 1_000_000, null, null)] // Somewhere in the middle
[InlineData(KdfType.PBKDF2_SHA256, 600_000, null, null)] // Right on the lower boundary
[InlineData(KdfType.PBKDF2_SHA256, 2_000_000, null, null)] // Right on the upper boundary
[InlineData(KdfType.Argon2id, 5, 500, 8)] // Somewhere in the middle
[InlineData(KdfType.Argon2id, 2, 15, 1)] // Right on the lower boundary
[InlineData(KdfType.Argon2id, 10, 1024, 16)] // Right on the upper boundary
public void Validate_IsValid(KdfType kdfType, int? kdfIterations, int? kdfMemory, int? kdfParallelism)
{
var model = new KdfRequestModel
{
Kdf = kdfType,
KdfIterations = kdfIterations,
KdfMemory = kdfMemory,
KdfParallelism = kdfParallelism,
Key = "TEST",
NewMasterPasswordHash = "TEST",
};
var results = Validate(model);
Assert.Empty(results);
}
[Theory]
[InlineData(null, 350_000, null, null, 1)] // Although KdfType is nullable, it's marked as [Required]
[InlineData(KdfType.PBKDF2_SHA256, 500_000, null, null, 1)] // Too few iterations
[InlineData(KdfType.PBKDF2_SHA256, 2_000_001, null, null, 1)] // Too many iterations
[InlineData(KdfType.Argon2id, 0, 30, 8, 1)] // Iterations must be greater than 0
[InlineData(KdfType.Argon2id, 10, 14, 8, 1)] // Too little memory
[InlineData(KdfType.Argon2id, 10, 14, 0, 1)] // Too small of a parallelism value
[InlineData(KdfType.Argon2id, 10, 1025, 8, 1)] // Too much memory
[InlineData(KdfType.Argon2id, 10, 512, 17, 1)] // Too big of a parallelism value
public void Validate_Fails(KdfType? kdfType, int? kdfIterations, int? kdfMemory, int? kdfParallelism, int expectedFailures)
{
var model = new KdfRequestModel
{
Kdf = kdfType,
KdfIterations = kdfIterations,
KdfMemory = kdfMemory,
KdfParallelism = kdfParallelism,
Key = "TEST",
NewMasterPasswordHash = "TEST",
};
var results = Validate(model);
Assert.NotEmpty(results);
Assert.Equal(expectedFailures, results.Count);
}
public static List<ValidationResult> Validate(KdfRequestModel model)
{
var results = new List<ValidationResult>();
Validator.TryValidateObject(model, new ValidationContext(model), results);
return results;
}
}

View File

@@ -0,0 +1,36 @@
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Xunit;
namespace Bit.Api.Test.Utilities;
public class KdfSettingsValidatorTests
{
[Theory]
[InlineData(KdfType.PBKDF2_SHA256, 1_000_000, null, null)] // Somewhere in the middle
[InlineData(KdfType.PBKDF2_SHA256, 600_000, null, null)] // Right on the lower boundary
[InlineData(KdfType.PBKDF2_SHA256, 2_000_000, null, null)] // Right on the upper boundary
[InlineData(KdfType.Argon2id, 5, 500, 8)] // Somewhere in the middle
[InlineData(KdfType.Argon2id, 2, 15, 1)] // Right on the lower boundary
[InlineData(KdfType.Argon2id, 10, 1024, 16)] // Right on the upper boundary
public void Validate_IsValid(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism)
{
var results = KdfSettingsValidator.Validate(kdfType, kdfIterations, kdfMemory, kdfParallelism);
Assert.Empty(results);
}
[Theory]
[InlineData(KdfType.PBKDF2_SHA256, 500_000, null, null, 1)] // Too few iterations
[InlineData(KdfType.PBKDF2_SHA256, 2_000_001, null, null, 1)] // Too many iterations
[InlineData(KdfType.Argon2id, 0, 30, 8, 1)] // Iterations must be greater than 0
[InlineData(KdfType.Argon2id, 10, 14, 8, 1)] // Too little memory
[InlineData(KdfType.Argon2id, 10, 14, 0, 1)] // Too small of a parallelism value
[InlineData(KdfType.Argon2id, 10, 1025, 8, 1)] // Too much memory
[InlineData(KdfType.Argon2id, 10, 512, 17, 1)] // Too big of a parallelism value
public void Validate_Fails(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism, int expectedFailures)
{
var results = KdfSettingsValidator.Validate(kdfType, kdfIterations, kdfMemory, kdfParallelism);
Assert.NotEmpty(results);
Assert.Equal(expectedFailures, results.Count());
}
}

View File

@@ -10,6 +10,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
@@ -471,18 +472,32 @@ public class ConfirmOrganizationUserCommandTests
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true);
var policyDetails = new PolicyDetails
{
OrganizationId = organization.Id,
OrganizationUserId = orgUser.Id,
IsProvider = false,
OrganizationUserStatus = orgUser.Status,
OrganizationUserType = orgUser.Type,
PolicyType = PolicyType.OrganizationDataOwnership
};
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(organization.Id)
.Returns(new List<Guid> { orgUser.Id });
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(orgUser.UserId!.Value)
.Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Enabled, [policyDetails]));
await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName);
await sutProvider.GetDependency<ICollectionRepository>()
.Received(1)
.UpsertDefaultCollectionsAsync(
organization.Id,
Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(orgUser.Id)),
collectionName);
.CreateAsync(
Arg.Is<Collection>(c =>
c.Name == collectionName &&
c.OrganizationId == organization.Id &&
c.Type == CollectionType.DefaultUserCollection),
Arg.Any<IEnumerable<CollectionAccessSelection>>(),
Arg.Is<IEnumerable<CollectionAccessSelection>>(cu =>
cu.Single().Id == orgUser.Id &&
cu.Single().Manage));
}
[Theory, BitAutoData]
@@ -511,7 +526,7 @@ public class ConfirmOrganizationUserCommandTests
[Theory, BitAutoData]
public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithOrganizationDataOwnershipPolicyNotApplicable_DoesNotCreateDefaultCollection(
Organization org, OrganizationUser confirmingUser,
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
[OrganizationUser(OrganizationUserStatusType.Accepted, OrganizationUserType.Owner)] OrganizationUser orgUser, User user,
string key, string collectionName, SutProvider<ConfirmOrganizationUserCommand> sutProvider)
{
org.PlanType = PlanType.EnterpriseAnnually;
@@ -523,9 +538,18 @@ public class ConfirmOrganizationUserCommandTests
sutProvider.GetDependency<IUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true);
var policyDetails = new PolicyDetails
{
OrganizationId = org.Id,
OrganizationUserId = orgUser.Id,
IsProvider = false,
OrganizationUserStatus = orgUser.Status,
OrganizationUserType = orgUser.Type,
PolicyType = PolicyType.OrganizationDataOwnership
};
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(org.Id)
.Returns(new List<Guid> { orgUser.UserId!.Value });
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(orgUser.UserId!.Value)
.Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [policyDetails]));
await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName);

View File

@@ -1,4 +1,4 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -17,12 +17,12 @@ using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
[SutProviderCustomize]
public class DeleteClaimedOrganizationUserAccountCommandvNextTests
public class DeleteClaimedOrganizationUserAccountCommandTests
{
[Theory]
[BitAutoData]
public async Task DeleteUserAsync_WithValidSingleUser_CallsDeleteManyUsersAsync(
SutProvider<DeleteClaimedOrganizationUserAccountCommandvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
User user,
Guid organizationId,
Guid deletingUserId,
@@ -65,7 +65,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
[Theory]
[BitAutoData]
public async Task DeleteManyUsersAsync_WithEmptyUserIds_ReturnsEmptyResults(
SutProvider<DeleteClaimedOrganizationUserAccountCommandvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
Guid organizationId,
Guid deletingUserId)
{
@@ -77,7 +77,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
[Theory]
[BitAutoData]
public async Task DeleteManyUsersAsync_WithValidUsers_DeletesUsersAndLogsEvents(
SutProvider<DeleteClaimedOrganizationUserAccountCommandvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
User user1,
User user2,
Guid organizationId,
@@ -135,7 +135,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
[Theory]
[BitAutoData]
public async Task DeleteManyUsersAsync_WithValidationErrors_ReturnsErrorResults(
SutProvider<DeleteClaimedOrganizationUserAccountCommandvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
Guid organizationId,
Guid orgUserId1,
Guid orgUserId2,
@@ -183,7 +183,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
[Theory]
[BitAutoData]
public async Task DeleteManyUsersAsync_WithMixedValidationResults_HandlesPartialSuccessCorrectly(
SutProvider<DeleteClaimedOrganizationUserAccountCommandvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
User validUser,
Guid organizationId,
Guid validOrgUserId,
@@ -243,7 +243,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
[Theory]
[BitAutoData]
public async Task DeleteManyUsersAsync_CancelPremiumsAsync_HandlesGatewayExceptionAndLogsWarning(
SutProvider<DeleteClaimedOrganizationUserAccountCommandvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
User user,
Guid organizationId,
Guid deletingUserId,
@@ -285,7 +285,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
await sutProvider.GetDependency<IUserService>().Received(1).CancelPremiumAsync(user);
await AssertSuccessfulUserOperations(sutProvider, [user], [orgUser]);
sutProvider.GetDependency<ILogger<DeleteClaimedOrganizationUserAccountCommandvNext>>()
sutProvider.GetDependency<ILogger<DeleteClaimedOrganizationUserAccountCommand>>()
.Received(1)
.Log(
LogLevel.Warning,
@@ -299,7 +299,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
[Theory]
[BitAutoData]
public async Task CreateInternalRequests_CreatesCorrectRequestsForAllUsers(
SutProvider<DeleteClaimedOrganizationUserAccountCommandvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
User user1,
User user2,
Guid organizationId,
@@ -326,7 +326,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
.GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(claimedStatuses);
sutProvider.GetDependency<IDeleteClaimedOrganizationUserAccountValidatorvNext>()
sutProvider.GetDependency<IDeleteClaimedOrganizationUserAccountValidator>()
.ValidateAsync(Arg.Any<IEnumerable<DeleteUserValidationRequest>>())
.Returns(callInfo =>
{
@@ -338,7 +338,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
await sutProvider.Sut.DeleteManyUsersAsync(organizationId, orgUserIds, deletingUserId);
// Assert
await sutProvider.GetDependency<IDeleteClaimedOrganizationUserAccountValidatorvNext>()
await sutProvider.GetDependency<IDeleteClaimedOrganizationUserAccountValidator>()
.Received(1)
.ValidateAsync(Arg.Is<IEnumerable<DeleteUserValidationRequest>>(requests =>
requests.Count() == 2 &&
@@ -359,7 +359,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
[Theory]
[BitAutoData]
public async Task GetUsersAsync_WithNullUserIds_ReturnsEmptyCollection(
SutProvider<DeleteClaimedOrganizationUserAccountCommandvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
Guid organizationId,
Guid deletingUserId,
[OrganizationUser] OrganizationUser orgUserWithoutUserId)
@@ -374,7 +374,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
.GetManyAsync(Arg.Is<IEnumerable<Guid>>(ids => !ids.Any()))
.Returns([]);
sutProvider.GetDependency<IDeleteClaimedOrganizationUserAccountValidatorvNext>()
sutProvider.GetDependency<IDeleteClaimedOrganizationUserAccountValidator>()
.ValidateAsync(Arg.Any<IEnumerable<DeleteUserValidationRequest>>())
.Returns(callInfo =>
{
@@ -386,7 +386,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUserWithoutUserId.Id], deletingUserId);
// Assert
await sutProvider.GetDependency<IDeleteClaimedOrganizationUserAccountValidatorvNext>()
await sutProvider.GetDependency<IDeleteClaimedOrganizationUserAccountValidator>()
.Received(1)
.ValidateAsync(Arg.Is<IEnumerable<DeleteUserValidationRequest>>(requests =>
requests.Count() == 1 &&
@@ -406,7 +406,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
ValidationResultHelpers.Invalid(request, error);
private static void SetupRepositoryMocks(
SutProvider<DeleteClaimedOrganizationUserAccountCommandvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
ICollection<OrganizationUser> orgUsers,
IEnumerable<User> users,
Guid organizationId,
@@ -426,16 +426,16 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
}
private static void SetupValidatorMock(
SutProvider<DeleteClaimedOrganizationUserAccountCommandvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
IEnumerable<ValidationResult<DeleteUserValidationRequest>> validationResults)
{
sutProvider.GetDependency<IDeleteClaimedOrganizationUserAccountValidatorvNext>()
sutProvider.GetDependency<IDeleteClaimedOrganizationUserAccountValidator>()
.ValidateAsync(Arg.Any<IEnumerable<DeleteUserValidationRequest>>())
.Returns(validationResults);
}
private static async Task AssertSuccessfulUserOperations(
SutProvider<DeleteClaimedOrganizationUserAccountCommandvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
IEnumerable<User> expectedUsers,
IEnumerable<OrganizationUser> expectedOrgUsers)
{
@@ -457,7 +457,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests
events.Any(e => e.Item1.Id == expectedOrgUser.Id && e.Item2 == EventType.OrganizationUser_Deleted))));
}
private static async Task AssertNoUserOperations(SutProvider<DeleteClaimedOrganizationUserAccountCommandvNext> sutProvider)
private static async Task AssertNoUserOperations(SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider)
{
await sutProvider.GetDependency<IUserRepository>().DidNotReceiveWithAnyArgs().DeleteManyAsync(default);
await sutProvider.GetDependency<IPushNotificationService>().DidNotReceiveWithAnyArgs().PushLogOutAsync(default);

View File

@@ -1,4 +1,4 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
@@ -13,12 +13,12 @@ using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
[SutProviderCustomize]
public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
public class DeleteClaimedOrganizationUserAccountValidatorTests
{
[Theory]
[BitAutoData]
public async Task ValidateAsync_WithValidSingleRequest_ReturnsValidResult(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
User user,
Guid organizationId,
Guid deletingUserId,
@@ -50,7 +50,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
[Theory]
[BitAutoData]
public async Task ValidateAsync_WithMultipleValidRequests_ReturnsAllValidResults(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
User user1,
User user2,
Guid organizationId,
@@ -97,7 +97,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
[Theory]
[BitAutoData]
public async Task ValidateAsync_WithNullUser_ReturnsUserNotFoundError(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
Guid organizationId,
Guid deletingUserId,
[OrganizationUser] OrganizationUser organizationUser)
@@ -123,7 +123,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
[Theory]
[BitAutoData]
public async Task ValidateAsync_WithNullOrganizationUser_ReturnsUserNotFoundError(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
User user,
Guid organizationId,
Guid deletingUserId)
@@ -149,7 +149,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
[Theory]
[BitAutoData]
public async Task ValidateAsync_WithInvitedUser_ReturnsInvalidUserStatusError(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
User user,
Guid organizationId,
Guid deletingUserId,
@@ -178,7 +178,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
[Theory]
[BitAutoData]
public async Task ValidateAsync_WhenDeletingYourself_ReturnsCannotDeleteYourselfError(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
User user,
Guid organizationId,
[OrganizationUser] OrganizationUser organizationUser)
@@ -206,7 +206,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
[Theory]
[BitAutoData]
public async Task ValidateAsync_WithUnclaimedUser_ReturnsUserNotClaimedError(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
User user,
Guid organizationId,
Guid deletingUserId,
@@ -235,7 +235,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
[Theory]
[BitAutoData]
public async Task ValidateAsync_DeletingOwnerWhenCurrentUserIsNotOwner_ReturnsCannotDeleteOwnersError(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
User user,
Guid organizationId,
Guid deletingUserId,
@@ -266,7 +266,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
[Theory]
[BitAutoData]
public async Task ValidateAsync_DeletingOwnerWhenCurrentUserIsOwner_ReturnsValidResult(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
User user,
Guid organizationId,
Guid deletingUserId,
@@ -296,7 +296,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
[Theory]
[BitAutoData]
public async Task ValidateAsync_WithSoleOwnerOfOrganization_ReturnsSoleOwnerError(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
User user,
Guid organizationId,
Guid deletingUserId,
@@ -331,7 +331,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
[Theory]
[BitAutoData]
public async Task ValidateAsync_WithSoleProviderOwner_ReturnsSoleProviderError(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
User user,
Guid organizationId,
Guid deletingUserId,
@@ -366,7 +366,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
[Theory]
[BitAutoData]
public async Task ValidateAsync_CustomUserDeletingAdmin_ReturnsCannotDeleteAdminsError(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
User user,
Guid organizationId,
Guid deletingUserId,
@@ -397,7 +397,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
[Theory]
[BitAutoData]
public async Task ValidateAsync_AdminDeletingAdmin_ReturnsValidResult(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
User user,
Guid organizationId,
Guid deletingUserId,
@@ -427,7 +427,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
[Theory]
[BitAutoData]
public async Task ValidateAsync_WithMixedValidAndInvalidRequests_ReturnsCorrespondingResults(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
User validUser,
User invalidUser,
Guid organizationId,
@@ -475,7 +475,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests
}
private static void SetupMocks(
SutProvider<DeleteClaimedOrganizationUserAccountValidatorvNext> sutProvider,
SutProvider<DeleteClaimedOrganizationUserAccountValidator> sutProvider,
Guid organizationId,
Guid userId,
OrganizationUserType currentUserType = OrganizationUserType.Owner)

View File

@@ -1,526 +0,0 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
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 NSubstitute;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers;
[SutProviderCustomize]
public class DeleteClaimedOrganizationUserAccountCommandTests
{
[Theory]
[BitAutoData]
public async Task DeleteUserAsync_WithValidUser_DeletesUserAndLogsEvent(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider, User user, Guid deletingUserId,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser)
{
// Arrange
organizationUser.UserId = user.Id;
sutProvider.GetDependency<IUserRepository>()
.GetByIdAsync(user.Id)
.Returns(user);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
sutProvider.GetDependency<IGetOrganizationUsersClaimedStatusQuery>()
.GetUsersOrganizationClaimedStatusAsync(
organizationUser.OrganizationId,
Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(organizationUser.Id)))
.Returns(new Dictionary<Guid, bool> { { organizationUser.Id, true } });
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(
organizationUser.OrganizationId,
Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(organizationUser.Id)),
includeProvider: Arg.Any<bool>())
.Returns(true);
// Act
await sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId);
// Assert
await sutProvider.GetDependency<IUserService>().Received(1).DeleteAsync(user);
await sutProvider.GetDependency<IEventService>().Received(1)
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Deleted);
}
[Theory]
[BitAutoData]
public async Task DeleteUserAsync_WithUserNotFound_ThrowsException(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
Guid organizationId, Guid organizationUserId)
{
// Arrange
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUserId)
.Returns((OrganizationUser?)null);
// Act
var exception = await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.DeleteUserAsync(organizationId, organizationUserId, null));
// Assert
Assert.Equal("Member not found.", exception.Message);
await sutProvider.GetDependency<IUserService>().Received(0).DeleteAsync(Arg.Any<User>());
await sutProvider.GetDependency<IEventService>().Received(0)
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<DateTime?>());
}
[Theory]
[BitAutoData]
public async Task DeleteUserAsync_DeletingYourself_ThrowsException(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
User user,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser,
Guid deletingUserId)
{
// Arrange
organizationUser.UserId = user.Id = deletingUserId;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id)
.Returns(user);
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId));
// Assert
Assert.Equal("You cannot delete yourself.", exception.Message);
await sutProvider.GetDependency<IUserService>().Received(0).DeleteAsync(Arg.Any<User>());
await sutProvider.GetDependency<IEventService>().Received(0)
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<DateTime?>());
}
[Theory]
[BitAutoData]
public async Task DeleteUserAsync_WhenUserIsInvited_ThrowsException(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
[OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser organizationUser)
{
// Arrange
organizationUser.UserId = null;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, null));
// Assert
Assert.Equal("You cannot delete a member with Invited status.", exception.Message);
await sutProvider.GetDependency<IUserService>().Received(0).DeleteAsync(Arg.Any<User>());
await sutProvider.GetDependency<IEventService>().Received(0)
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<DateTime?>());
}
[Theory]
[BitAutoData]
public async Task DeleteUserAsync_WhenCustomUserDeletesAdmin_ThrowsException(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider, User user,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Admin)] OrganizationUser organizationUser,
Guid deletingUserId)
{
// Arrange
organizationUser.UserId = user.Id;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id)
.Returns(user);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationCustom(organizationUser.OrganizationId)
.Returns(true);
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId));
// Assert
Assert.Equal("Custom users can not delete admins.", exception.Message);
await sutProvider.GetDependency<IUserService>().Received(0).DeleteAsync(Arg.Any<User>());
await sutProvider.GetDependency<IEventService>().Received(0)
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<DateTime?>());
}
[Theory]
[BitAutoData]
public async Task DeleteUserAsync_DeletingOwnerWhenNotOwner_ThrowsException(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider, User user,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser,
Guid deletingUserId)
{
// Arrange
organizationUser.UserId = user.Id;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id)
.Returns(user);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationUser.OrganizationId)
.Returns(false);
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId));
// Assert
Assert.Equal("Only owners can delete other owners.", exception.Message);
await sutProvider.GetDependency<IUserService>().Received(0).DeleteAsync(Arg.Any<User>());
await sutProvider.GetDependency<IEventService>().Received(0)
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<DateTime?>());
}
[Theory]
[BitAutoData]
public async Task DeleteUserAsync_DeletingLastConfirmedOwner_ThrowsException(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider, User user,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser,
Guid deletingUserId)
{
// Arrange
organizationUser.UserId = user.Id;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id)
.Returns(user);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationUser.OrganizationId)
.Returns(true);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(
organizationUser.OrganizationId,
Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(organizationUser.Id)),
includeProvider: Arg.Any<bool>())
.Returns(false);
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId));
// Assert
Assert.Equal("Organization must have at least one confirmed owner.", exception.Message);
await sutProvider.GetDependency<IUserService>().Received(0).DeleteAsync(Arg.Any<User>());
await sutProvider.GetDependency<IEventService>().Received(0)
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<DateTime?>());
}
[Theory]
[BitAutoData]
public async Task DeleteUserAsync_WithUserNotManaged_ThrowsException(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider, User user,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser)
{
// Arrange
organizationUser.UserId = user.Id;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUser.Id)
.Returns(organizationUser);
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id)
.Returns(user);
sutProvider.GetDependency<IGetOrganizationUsersClaimedStatusQuery>()
.GetUsersOrganizationClaimedStatusAsync(organizationUser.OrganizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(new Dictionary<Guid, bool> { { organizationUser.Id, false } });
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, null));
// Assert
Assert.Equal("Member is not claimed by the organization.", exception.Message);
await sutProvider.GetDependency<IUserService>().Received(0).DeleteAsync(Arg.Any<User>());
await sutProvider.GetDependency<IEventService>().Received(0)
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<DateTime?>());
}
[Theory]
[BitAutoData]
public async Task DeleteManyUsersAsync_WithValidUsers_DeletesUsersAndLogsEvents(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider, User user1, User user2, Guid organizationId,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2)
{
// Arrange
orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId;
orgUser1.UserId = user1.Id;
orgUser2.UserId = user2.Id;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(Arg.Any<IEnumerable<Guid>>())
.Returns(new List<OrganizationUser> { orgUser1, orgUser2 });
sutProvider.GetDependency<IUserRepository>()
.GetManyAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id)))
.Returns(new[] { user1, user2 });
sutProvider.GetDependency<IGetOrganizationUsersClaimedStatusQuery>()
.GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(new Dictionary<Guid, bool> { { orgUser1.Id, true }, { orgUser2.Id, true } });
// Act
var userIds = new[] { orgUser1.Id, orgUser2.Id };
var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, userIds, null);
// Assert
Assert.Equal(2, results.Count());
Assert.All(results, r => Assert.Empty(r.Item2));
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).GetManyAsync(userIds);
await sutProvider.GetDependency<IUserRepository>().Received(1).DeleteManyAsync(Arg.Is<IEnumerable<User>>(users => users.Any(u => u.Id == user1.Id) && users.Any(u => u.Id == user2.Id)));
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventsAsync(
Arg.Is<IEnumerable<(OrganizationUser, EventType, DateTime?)>>(events =>
events.Count(e => e.Item1.Id == orgUser1.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1
&& events.Count(e => e.Item1.Id == orgUser2.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1));
}
[Theory]
[BitAutoData]
public async Task DeleteManyUsersAsync_WhenUserNotFound_ReturnsErrorMessage(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
Guid organizationId,
Guid orgUserId)
{
// Act
var result = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, new[] { orgUserId }, null);
// Assert
Assert.Single(result);
Assert.Equal(orgUserId, result.First().Item1);
Assert.Contains("Member not found.", result.First().Item2);
await sutProvider.GetDependency<IUserRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteManyAsync(default);
await sutProvider.GetDependency<IEventService>().Received(0)
.LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>());
}
[Theory]
[BitAutoData]
public async Task DeleteManyUsersAsync_WhenDeletingYourself_ReturnsErrorMessage(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
User user, [OrganizationUser] OrganizationUser orgUser, Guid deletingUserId)
{
// Arrange
orgUser.UserId = user.Id = deletingUserId;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(Arg.Any<IEnumerable<Guid>>())
.Returns(new List<OrganizationUser> { orgUser });
sutProvider.GetDependency<IUserRepository>()
.GetManyAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(user.Id)))
.Returns(new[] { user });
// Act
var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, deletingUserId);
// Assert
Assert.Single(result);
Assert.Equal(orgUser.Id, result.First().Item1);
Assert.Contains("You cannot delete yourself.", result.First().Item2);
await sutProvider.GetDependency<IUserService>().Received(0).DeleteAsync(Arg.Any<User>());
await sutProvider.GetDependency<IEventService>().Received(0)
.LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>());
}
[Theory]
[BitAutoData]
public async Task DeleteManyUsersAsync_WhenUserIsInvited_ReturnsErrorMessage(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider,
[OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser orgUser)
{
// Arrange
orgUser.UserId = null;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(Arg.Any<IEnumerable<Guid>>())
.Returns(new List<OrganizationUser> { orgUser });
// Act
var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, null);
// Assert
Assert.Single(result);
Assert.Equal(orgUser.Id, result.First().Item1);
Assert.Contains("You cannot delete a member with Invited status.", result.First().Item2);
await sutProvider.GetDependency<IUserService>().Received(0).DeleteAsync(Arg.Any<User>());
await sutProvider.GetDependency<IEventService>().Received(0)
.LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>());
}
[Theory]
[BitAutoData]
public async Task DeleteManyUsersAsync_WhenDeletingOwnerAsNonOwner_ReturnsErrorMessage(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider, User user,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser,
Guid deletingUserId)
{
// Arrange
orgUser.UserId = user.Id;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(Arg.Any<IEnumerable<Guid>>())
.Returns(new List<OrganizationUser> { orgUser });
sutProvider.GetDependency<IUserRepository>()
.GetManyAsync(Arg.Is<IEnumerable<Guid>>(i => i.Contains(user.Id)))
.Returns(new[] { user });
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(orgUser.OrganizationId)
.Returns(false);
var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, deletingUserId);
Assert.Single(result);
Assert.Equal(orgUser.Id, result.First().Item1);
Assert.Contains("Only owners can delete other owners.", result.First().Item2);
await sutProvider.GetDependency<IUserService>().Received(0).DeleteAsync(Arg.Any<User>());
await sutProvider.GetDependency<IEventService>().Received(0)
.LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>());
}
[Theory]
[BitAutoData]
public async Task DeleteManyUsersAsync_WhenDeletingLastOwner_ReturnsErrorMessage(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider, User user,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser,
Guid deletingUserId)
{
// Arrange
orgUser.UserId = user.Id;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(Arg.Any<IEnumerable<Guid>>())
.Returns(new List<OrganizationUser> { orgUser });
sutProvider.GetDependency<IUserRepository>()
.GetManyAsync(Arg.Is<IEnumerable<Guid>>(i => i.Contains(user.Id)))
.Returns(new[] { user });
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(orgUser.OrganizationId)
.Returns(true);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(orgUser.OrganizationId, Arg.Any<IEnumerable<Guid>>(), Arg.Any<bool>())
.Returns(false);
// Act
var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, deletingUserId);
// Assert
Assert.Single(result);
Assert.Equal(orgUser.Id, result.First().Item1);
Assert.Contains("Organization must have at least one confirmed owner.", result.First().Item2);
await sutProvider.GetDependency<IUserService>().Received(0).DeleteAsync(Arg.Any<User>());
await sutProvider.GetDependency<IEventService>().Received(0)
.LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>());
}
[Theory]
[BitAutoData]
public async Task DeleteManyUsersAsync_WhenUserNotManaged_ReturnsErrorMessage(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider, User user,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser)
{
// Arrange
orgUser.UserId = user.Id;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(Arg.Any<IEnumerable<Guid>>())
.Returns(new List<OrganizationUser> { orgUser });
sutProvider.GetDependency<IUserRepository>()
.GetManyAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(orgUser.UserId.Value)))
.Returns(new[] { user });
sutProvider.GetDependency<IGetOrganizationUsersClaimedStatusQuery>()
.GetUsersOrganizationClaimedStatusAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>())
.Returns(new Dictionary<Guid, bool> { { orgUser.Id, false } });
// Act
var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, null);
// Assert
Assert.Single(result);
Assert.Equal(orgUser.Id, result.First().Item1);
Assert.Contains("Member is not claimed by the organization.", result.First().Item2);
await sutProvider.GetDependency<IUserService>().Received(0).DeleteAsync(Arg.Any<User>());
await sutProvider.GetDependency<IEventService>().Received(0)
.LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>());
}
[Theory]
[BitAutoData]
public async Task DeleteManyUsersAsync_MixedValidAndInvalidUsers_ReturnsAppropriateResults(
SutProvider<DeleteClaimedOrganizationUserAccountCommand> sutProvider, User user1, User user3,
Guid organizationId,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1,
[OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser orgUser2,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser3)
{
// Arrange
orgUser1.UserId = user1.Id;
orgUser2.UserId = null;
orgUser3.UserId = user3.Id;
orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = organizationId;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(Arg.Any<IEnumerable<Guid>>())
.Returns(new List<OrganizationUser> { orgUser1, orgUser2, orgUser3 });
sutProvider.GetDependency<IUserRepository>()
.GetManyAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(user1.Id) && ids.Contains(user3.Id)))
.Returns(new[] { user1, user3 });
sutProvider.GetDependency<IGetOrganizationUsersClaimedStatusQuery>()
.GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(new Dictionary<Guid, bool> { { orgUser1.Id, true }, { orgUser3.Id, false } });
// Act
var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, new[] { orgUser1.Id, orgUser2.Id, orgUser3.Id }, null);
// Assert
Assert.Equal(3, results.Count());
Assert.Empty(results.First(r => r.Item1 == orgUser1.Id).Item2);
Assert.Equal("You cannot delete a member with Invited status.", results.First(r => r.Item1 == orgUser2.Id).Item2);
Assert.Equal("Member is not claimed by the organization.", results.First(r => r.Item1 == orgUser3.Id).Item2);
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventsAsync(
Arg.Is<IEnumerable<(OrganizationUser, EventType, DateTime?)>>(events =>
events.Count(e => e.Item1.Id == orgUser1.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1));
}
}

View File

@@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
@@ -13,11 +14,14 @@ using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Stripe;
using Stripe.Tax;
using Stripe.TestHelpers;
using Xunit;
namespace Bit.Core.Test.Billing.Organizations.Queries;
using static StripeConstants;
[SutProviderCustomize]
public class GetOrganizationWarningsQueryTests
{
@@ -57,7 +61,7 @@ public class GetOrganizationWarningsQueryTests
))
.Returns(new Subscription
{
Status = StripeConstants.SubscriptionStatus.Trialing,
Status = SubscriptionStatus.Trialing,
TrialEnd = now.AddDays(7),
Customer = new Customer
{
@@ -95,7 +99,7 @@ public class GetOrganizationWarningsQueryTests
))
.Returns(new Subscription
{
Status = StripeConstants.SubscriptionStatus.Trialing,
Status = SubscriptionStatus.Trialing,
TrialEnd = now.AddDays(7),
Customer = new Customer
{
@@ -142,7 +146,7 @@ public class GetOrganizationWarningsQueryTests
))
.Returns(new Subscription
{
Status = StripeConstants.SubscriptionStatus.Unpaid,
Status = SubscriptionStatus.Unpaid,
Customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
@@ -170,7 +174,8 @@ public class GetOrganizationWarningsQueryTests
))
.Returns(new Subscription
{
Status = StripeConstants.SubscriptionStatus.Unpaid
Customer = new Customer(),
Status = SubscriptionStatus.Unpaid
});
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id)
@@ -197,7 +202,8 @@ public class GetOrganizationWarningsQueryTests
))
.Returns(new Subscription
{
Status = StripeConstants.SubscriptionStatus.Unpaid
Customer = new Customer(),
Status = SubscriptionStatus.Unpaid
});
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
@@ -223,7 +229,8 @@ public class GetOrganizationWarningsQueryTests
))
.Returns(new Subscription
{
Status = StripeConstants.SubscriptionStatus.Canceled
Customer = new Customer(),
Status = SubscriptionStatus.Canceled
});
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
@@ -249,7 +256,8 @@ public class GetOrganizationWarningsQueryTests
))
.Returns(new Subscription
{
Status = StripeConstants.SubscriptionStatus.Unpaid
Customer = new Customer(),
Status = SubscriptionStatus.Unpaid
});
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(false);
@@ -275,8 +283,9 @@ public class GetOrganizationWarningsQueryTests
))
.Returns(new Subscription
{
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
Status = StripeConstants.SubscriptionStatus.Active,
CollectionMethod = CollectionMethod.SendInvoice,
Customer = new Customer(),
Status = SubscriptionStatus.Active,
CurrentPeriodEnd = now.AddDays(10),
TestClock = new TestClock
{
@@ -313,11 +322,12 @@ public class GetOrganizationWarningsQueryTests
))
.Returns(new Subscription
{
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
Status = StripeConstants.SubscriptionStatus.Active,
CollectionMethod = CollectionMethod.SendInvoice,
Customer = new Customer(),
Status = SubscriptionStatus.Active,
LatestInvoice = new Invoice
{
Status = StripeConstants.InvoiceStatus.Open,
Status = InvoiceStatus.Open,
DueDate = now.AddDays(30),
Created = now
},
@@ -360,8 +370,9 @@ public class GetOrganizationWarningsQueryTests
.Returns(new Subscription
{
Id = subscriptionId,
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
Status = StripeConstants.SubscriptionStatus.PastDue,
CollectionMethod = CollectionMethod.SendInvoice,
Customer = new Customer(),
Status = SubscriptionStatus.PastDue,
TestClock = new TestClock
{
FrozenTime = now
@@ -390,4 +401,406 @@ public class GetOrganizationWarningsQueryTests
Assert.Equal(dueDate.AddDays(30), response.ResellerRenewal.PastDue!.SuspensionDate);
}
[Theory, BitAutoData]
public async Task Run_USCustomer_NoTaxIdWarning(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
var subscription = new Subscription
{
Customer = new Customer
{
Address = new Address { Country = "US" },
TaxIds = new StripeList<TaxId> { Data = new List<TaxId>() },
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
}
};
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
var response = await sutProvider.Sut.Run(organization);
Assert.Null(response.TaxId);
}
[Theory, BitAutoData]
public async Task Run_FreeCustomer_NoTaxIdWarning(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
organization.PlanType = PlanType.Free;
var subscription = new Subscription
{
Customer = new Customer
{
Address = new Address { Country = "CA" },
TaxIds = new StripeList<TaxId> { Data = new List<TaxId>() },
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
}
};
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
var response = await sutProvider.Sut.Run(organization);
Assert.Null(response.TaxId);
}
[Theory, BitAutoData]
public async Task Run_NotOwner_NoTaxIdWarning(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
organization.PlanType = PlanType.TeamsAnnually;
var subscription = new Subscription
{
Customer = new Customer
{
Address = new Address { Country = "CA" },
TaxIds = new StripeList<TaxId> { Data = new List<TaxId>() },
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
}
};
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organization.Id)
.Returns(false);
var response = await sutProvider.Sut.Run(organization);
Assert.Null(response.TaxId);
}
[Theory, BitAutoData]
public async Task Run_HasProvider_NoTaxIdWarning(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
organization.PlanType = PlanType.TeamsAnnually;
var subscription = new Subscription
{
Customer = new Customer
{
Address = new Address { Country = "CA" },
TaxIds = new StripeList<TaxId> { Data = new List<TaxId>() },
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
}
};
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organization.Id)
.Returns(true);
sutProvider.GetDependency<IProviderRepository>()
.GetByOrganizationIdAsync(organization.Id)
.Returns(new Provider());
var response = await sutProvider.Sut.Run(organization);
Assert.Null(response.TaxId);
}
[Theory, BitAutoData]
public async Task Run_NoRegistrationInCountry_NoTaxIdWarning(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
organization.PlanType = PlanType.TeamsAnnually;
var subscription = new Subscription
{
Customer = new Customer
{
Address = new Address { Country = "CA" },
TaxIds = new StripeList<TaxId> { Data = new List<TaxId>() },
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
}
};
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organization.Id)
.Returns(true);
sutProvider.GetDependency<IStripeAdapter>()
.TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = new List<Registration>
{
new() { Country = "GB" }
}
});
var response = await sutProvider.Sut.Run(organization);
Assert.Null(response.TaxId);
}
[Theory, BitAutoData]
public async Task Run_Has_TaxIdWarning_Missing(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
organization.PlanType = PlanType.TeamsAnnually;
var subscription = new Subscription
{
Customer = new Customer
{
Address = new Address { Country = "CA" },
TaxIds = new StripeList<TaxId> { Data = new List<TaxId>() },
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
}
};
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organization.Id)
.Returns(true);
sutProvider.GetDependency<IStripeAdapter>()
.TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = new List<Registration>
{
new() { Country = "CA" }
}
});
var response = await sutProvider.Sut.Run(organization);
Assert.True(response is
{
TaxId.Type: "tax_id_missing"
});
}
[Theory, BitAutoData]
public async Task Run_Has_TaxIdWarning_PendingVerification(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
organization.PlanType = PlanType.EnterpriseAnnually;
var taxId = new TaxId
{
Verification = new TaxIdVerification
{
Status = TaxIdVerificationStatus.Pending
}
};
var subscription = new Subscription
{
Customer = new Customer
{
Address = new Address { Country = "CA" },
TaxIds = new StripeList<TaxId> { Data = new List<TaxId> { taxId } },
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
}
};
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organization.Id)
.Returns(true);
sutProvider.GetDependency<IStripeAdapter>()
.TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = new List<Registration>
{
new() { Country = "CA" }
}
});
var response = await sutProvider.Sut.Run(organization);
Assert.True(response is
{
TaxId.Type: "tax_id_pending_verification"
});
}
[Theory, BitAutoData]
public async Task Run_Has_TaxIdWarning_FailedVerification(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
organization.PlanType = PlanType.TeamsAnnually;
var taxId = new TaxId
{
Verification = new TaxIdVerification
{
Status = TaxIdVerificationStatus.Unverified
}
};
var subscription = new Subscription
{
Customer = new Customer
{
Address = new Address { Country = "CA" },
TaxIds = new StripeList<TaxId> { Data = new List<TaxId> { taxId } },
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
}
};
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organization.Id)
.Returns(true);
sutProvider.GetDependency<IStripeAdapter>()
.TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = new List<Registration>
{
new() { Country = "CA" }
}
});
var response = await sutProvider.Sut.Run(organization);
Assert.True(response is
{
TaxId.Type: "tax_id_failed_verification"
});
}
[Theory, BitAutoData]
public async Task Run_VerifiedTaxId_NoTaxIdWarning(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
organization.PlanType = PlanType.TeamsAnnually;
var taxId = new TaxId
{
Verification = new TaxIdVerification
{
Status = TaxIdVerificationStatus.Verified
}
};
var subscription = new Subscription
{
Customer = new Customer
{
Address = new Address { Country = "CA" },
TaxIds = new StripeList<TaxId> { Data = new List<TaxId> { taxId } },
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
}
};
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organization.Id)
.Returns(true);
sutProvider.GetDependency<IStripeAdapter>()
.TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = new List<Registration>
{
new() { Country = "CA" }
}
});
var response = await sutProvider.Sut.Run(organization);
Assert.Null(response.TaxId);
}
[Theory, BitAutoData]
public async Task Run_NullVerification_NoTaxIdWarning(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
organization.PlanType = PlanType.TeamsAnnually;
var taxId = new TaxId
{
Verification = null
};
var subscription = new Subscription
{
Customer = new Customer
{
Address = new Address { Country = "CA" },
TaxIds = new StripeList<TaxId> { Data = new List<TaxId> { taxId } },
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
}
};
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organization.Id)
.Returns(true);
sutProvider.GetDependency<IStripeAdapter>()
.TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = new List<Registration>
{
new() { Country = "CA" }
}
});
var response = await sutProvider.Sut.Run(organization);
Assert.Null(response.TaxId);
}
}

View File

@@ -0,0 +1,322 @@
#nullable enable
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Kdf.Implementations;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Identity;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.KeyManagement.Kdf;
[SutProviderCustomize]
public class ChangeKdfCommandTests
{
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider<ChangeKdfCommand> sutProvider, User user)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(IdentityResult.Success));
var kdf = new KdfSettings
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 4,
Memory = 512,
Parallelism = 4
};
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = kdf,
MasterPasswordAuthenticationHash = "newMasterPassword",
Salt = user.GetMasterPasswordSalt()
};
var unlockData = new MasterPasswordUnlockData
{
Kdf = kdf,
MasterKeyWrappedUserKey = "masterKeyWrappedUserKey",
Salt = user.GetMasterPasswordSalt()
};
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData);
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync(Arg.Is<User>(u =>
u.Id == user.Id
&& u.Kdf == Enums.KdfType.Argon2id
&& u.KdfIterations == 4
&& u.KdfMemory == 512
&& u.KdfParallelism == 4
));
}
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_UserIsNull_ThrowsArgumentNullException(SutProvider<ChangeKdfCommand> sutProvider)
{
var kdf = new KdfSettings
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 4,
Memory = 512,
Parallelism = 4
};
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = kdf,
MasterPasswordAuthenticationHash = "newMasterPassword",
Salt = "salt"
};
var unlockData = new MasterPasswordUnlockData
{
Kdf = kdf,
MasterKeyWrappedUserKey = "masterKeyWrappedUserKey",
Salt = "salt"
};
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
await sutProvider.Sut.ChangeKdfAsync(null!, "masterPassword", authenticationData, unlockData));
}
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_WrongPassword_ReturnsPasswordMismatch(SutProvider<ChangeKdfCommand> sutProvider, User user)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(false));
var kdf = new KdfSettings
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 4,
Memory = 512,
Parallelism = 4
};
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = kdf,
MasterPasswordAuthenticationHash = "newMasterPassword",
Salt = user.GetMasterPasswordSalt()
};
var unlockData = new MasterPasswordUnlockData
{
Kdf = kdf,
MasterKeyWrappedUserKey = "masterKeyWrappedUserKey",
Salt = user.GetMasterPasswordSalt()
};
var result = await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData);
Assert.False(result.Succeeded);
Assert.Contains(result.Errors, e => e.Code == "PasswordMismatch");
}
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_WithAuthenticationAndUnlockData_UpdatesUserCorrectly(SutProvider<ChangeKdfCommand> sutProvider, User user)
{
var constantKdf = new KdfSettings
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 5,
Memory = 1024,
Parallelism = 4
};
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = constantKdf,
MasterPasswordAuthenticationHash = "new-auth-hash",
Salt = user.GetMasterPasswordSalt()
};
var unlockData = new MasterPasswordUnlockData
{
Kdf = constantKdf,
MasterKeyWrappedUserKey = "new-wrapped-key",
Salt = user.GetMasterPasswordSalt()
};
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(IdentityResult.Success));
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData);
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync(Arg.Is<User>(u =>
u.Id == user.Id
&& u.Kdf == constantKdf.KdfType
&& u.KdfIterations == constantKdf.Iterations
&& u.KdfMemory == constantKdf.Memory
&& u.KdfParallelism == constantKdf.Parallelism
&& u.Key == "new-wrapped-key"
));
}
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_KdfNotEqualBetweenAuthAndUnlock_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 },
MasterPasswordAuthenticationHash = "new-auth-hash",
Salt = user.GetMasterPasswordSalt()
};
var unlockData = new MasterPasswordUnlockData
{
Kdf = new KdfSettings { KdfType = Enums.KdfType.PBKDF2_SHA256, Iterations = 100000 },
MasterKeyWrappedUserKey = "new-wrapped-key",
Salt = user.GetMasterPasswordSalt()
};
await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData));
}
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user, KdfSettings kdf)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = kdf,
MasterPasswordAuthenticationHash = "new-auth-hash",
Salt = "different-salt"
};
var unlockData = new MasterPasswordUnlockData
{
Kdf = kdf,
MasterKeyWrappedUserKey = "new-wrapped-key",
Salt = user.GetMasterPasswordSalt()
};
await Assert.ThrowsAsync<ArgumentException>(async () =>
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData));
}
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user, KdfSettings kdf)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = kdf,
MasterPasswordAuthenticationHash = "new-auth-hash",
Salt = user.GetMasterPasswordSalt()
};
var unlockData = new MasterPasswordUnlockData
{
Kdf = kdf,
MasterKeyWrappedUserKey = "new-wrapped-key",
Salt = "different-salt"
};
await Assert.ThrowsAsync<ArgumentException>(async () =>
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData));
}
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvider<ChangeKdfCommand> sutProvider, User user)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
var failedResult = IdentityResult.Failed(new IdentityError { Code = "TestFail", Description = "Test fail" });
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(failedResult));
var kdf = new KdfSettings
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 4,
Memory = 512,
Parallelism = 4
};
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = kdf,
MasterPasswordAuthenticationHash = "newMasterPassword",
Salt = user.GetMasterPasswordSalt()
};
var unlockData = new MasterPasswordUnlockData
{
Kdf = kdf,
MasterKeyWrappedUserKey = "masterKeyWrappedUserKey",
Salt = user.GetMasterPasswordSalt()
};
var result = await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData);
Assert.False(result.Succeeded);
}
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_InvalidKdfSettings_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
// Create invalid KDF settings (iterations too low for PBKDF2)
var invalidKdf = new KdfSettings
{
KdfType = Enums.KdfType.PBKDF2_SHA256,
Iterations = 1000, // This is below the minimum of 600,000
Memory = null,
Parallelism = null
};
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = invalidKdf,
MasterPasswordAuthenticationHash = "new-auth-hash",
Salt = user.GetMasterPasswordSalt()
};
var unlockData = new MasterPasswordUnlockData
{
Kdf = invalidKdf,
MasterKeyWrappedUserKey = "new-wrapped-key",
Salt = user.GetMasterPasswordSalt()
};
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData));
Assert.Equal("KDF settings are invalid.", exception.Message);
}
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_InvalidArgon2Settings_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
// Create invalid Argon2 KDF settings (memory too high)
var invalidKdf = new KdfSettings
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 3, // Valid
Memory = 2048, // This is above the maximum of 1024
Parallelism = 4 // Valid
};
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = invalidKdf,
MasterPasswordAuthenticationHash = "new-auth-hash",
Salt = user.GetMasterPasswordSalt()
};
var unlockData = new MasterPasswordUnlockData
{
Kdf = invalidKdf,
MasterKeyWrappedUserKey = "new-wrapped-key",
Salt = user.GetMasterPasswordSalt()
};
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData));
Assert.Equal("KDF settings are invalid.", exception.Message);
}
}

View File

@@ -0,0 +1,230 @@
using System.Security.Cryptography;
using System.Text;
using Bit.Core.Utilities;
using Xunit;
namespace Bit.Core.Test.Utilities;
public class EnumerationProtectionHelpersTests
{
#region GetIndexForInputHash Tests
[Fact]
public void GetIndexForInputHash_NullHmacKey_ReturnsZero()
{
// Arrange
byte[] hmacKey = null;
var salt = "test@example.com";
var range = 10;
// Act
var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
// Assert
Assert.Equal(0, result);
}
[Fact]
public void GetIndexForInputHash_ZeroRange_ReturnsZero()
{
// Arrange
var hmacKey = RandomNumberGenerator.GetBytes(32);
var salt = "test@example.com";
var range = 0;
// Act
var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
// Assert
Assert.Equal(0, result);
}
[Fact]
public void GetIndexForInputHash_NegativeRange_ReturnsZero()
{
// Arrange
var hmacKey = RandomNumberGenerator.GetBytes(32);
var salt = "test@example.com";
var range = -5;
// Act
var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
// Assert
Assert.Equal(0, result);
}
[Fact]
public void GetIndexForInputHash_ValidInputs_ReturnsConsistentResult()
{
// Arrange
var hmacKey = Encoding.UTF8.GetBytes("test-key-12345678901234567890123456789012");
var salt = "test@example.com";
var range = 10;
// Act
var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
// Assert
Assert.Equal(result1, result2);
Assert.InRange(result1, 0, range - 1);
}
[Fact]
public void GetIndexForInputHash_SameInputSameKey_AlwaysReturnsSameResult()
{
// Arrange
var hmacKey = RandomNumberGenerator.GetBytes(32);
var salt = "consistent@example.com";
var range = 100;
// Act - Call multiple times
var results = new int[10];
for (var i = 0; i < 10; i++)
{
results[i] = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
}
// Assert - All results should be identical
Assert.All(results, result => Assert.Equal(results[0], result));
Assert.All(results, result => Assert.InRange(result, 0, range - 1));
}
[Fact]
public void GetIndexForInputHash_DifferentInputsSameKey_ReturnsDifferentResults()
{
// Arrange
var hmacKey = RandomNumberGenerator.GetBytes(32);
var salt1 = "user1@example.com";
var salt2 = "user2@example.com";
var range = 100;
// Act
var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt1, range);
var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt2, range);
// Assert
Assert.NotEqual(result1, result2);
Assert.InRange(result1, 0, range - 1);
Assert.InRange(result2, 0, range - 1);
}
[Fact]
public void GetIndexForInputHash_DifferentKeysSameInput_ReturnsDifferentResults()
{
// Arrange
var hmacKey1 = RandomNumberGenerator.GetBytes(32);
var hmacKey2 = RandomNumberGenerator.GetBytes(32);
var salt = "test@example.com";
var range = 100;
// Act
var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey1, salt, range);
var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey2, salt, range);
// Assert
Assert.NotEqual(result1, result2);
Assert.InRange(result1, 0, range - 1);
Assert.InRange(result2, 0, range - 1);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(5)]
[InlineData(10)]
[InlineData(100)]
[InlineData(1000)]
public void GetIndexForInputHash_VariousRanges_ReturnsValidIndex(int range)
{
// Arrange
var hmacKey = RandomNumberGenerator.GetBytes(32);
var salt = "test@example.com";
// Act
var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
// Assert
Assert.InRange(result, 0, range - 1);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void GetIndexForInputHash_EmptyString_HandlesGracefully(string salt)
{
// Arrange
var hmacKey = RandomNumberGenerator.GetBytes(32);
// Act
var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, 10);
// Assert
Assert.InRange(result, 0, 9);
}
[Fact]
public void GetIndexForInputHash_NullInput_ThrowsException()
{
// Arrange
var hmacKey = RandomNumberGenerator.GetBytes(32);
string salt = null;
var range = 10;
// Act & Assert
Assert.Throws<NullReferenceException>(() =>
EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range));
}
[Fact]
public void GetIndexForInputHash_SpecialCharacters_HandlesCorrectly()
{
// Arrange
var hmacKey = RandomNumberGenerator.GetBytes(32);
var salt = "test+user@example.com!@#$%^&*()";
var range = 50;
// Act
var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
// Assert
Assert.Equal(result1, result2);
Assert.InRange(result1, 0, range - 1);
}
[Fact]
public void GetIndexForInputHash_UnicodeCharacters_HandlesCorrectly()
{
// Arrange
var hmacKey = RandomNumberGenerator.GetBytes(32);
var salt = "tëst@éxämplé.cöm";
var range = 25;
// Act
var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
// Assert
Assert.Equal(result1, result2);
Assert.InRange(result1, 0, range - 1);
}
[Fact]
public void GetIndexForInputHash_LongInput_HandlesCorrectly()
{
// Arrange
var hmacKey = RandomNumberGenerator.GetBytes(32);
var salt = new string('a', 1000) + "@example.com";
var range = 30;
// Act
var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
// Assert
Assert.InRange(result, 0, range - 1);
}
#endregion
}

View File

@@ -1,10 +1,8 @@
using Bit.Core;
using Bit.Core.Auth.IdentityServer;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
using Bit.Core.Utilities;
using Bit.Identity.IdentityServer.Enums;
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
using Bit.IntegrationTestCommon.Factories;
@@ -13,16 +11,14 @@ using Duende.IdentityServer.Validation;
using NSubstitute;
using Xunit;
namespace Bit.Identity.IntegrationTest.RequestValidation;
namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess;
// in order to test the default case for the authentication method, we need to create a custom one so we can ensure the
// method throws as expected.
internal record AnUnknownAuthenticationMethod : SendAuthenticationMethod { }
public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory factory) : IClassFixture<IdentityApplicationFactory>
public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture<IdentityApplicationFactory>
{
private readonly IdentityApplicationFactory _factory = factory;
[Fact]
public async Task SendAccessGrant_FeatureFlagDisabled_ReturnsUnsupportedGrantType()
{
@@ -39,7 +35,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
@@ -70,7 +66,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
@@ -125,7 +121,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
@@ -154,7 +150,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
@@ -183,7 +179,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId);
// Act
var error = await client.PostAsync("/connect/token", requestBody);
@@ -225,7 +221,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId, "password123");
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId, "password123");
// Act
var response = await client.PostAsync("/connect/token", requestBody);
@@ -236,37 +232,4 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory
Assert.Contains("access_token", content);
Assert.Contains("Bearer", content);
}
private static FormUrlEncodedContent CreateTokenRequestBody(
Guid sendId,
string password = null,
string sendEmail = null,
string emailOtp = null)
{
var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
var parameters = new List<KeyValuePair<string, string>>
{
new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess),
new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send ),
new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess),
new("deviceType", ((int)DeviceType.FirefoxBrowser).ToString()),
new(SendAccessConstants.TokenRequest.SendId, sendIdBase64)
};
if (!string.IsNullOrEmpty(password))
{
parameters.Add(new(SendAccessConstants.TokenRequest.ClientB64HashedPassword, password));
}
if (!string.IsNullOrEmpty(emailOtp) && !string.IsNullOrEmpty(sendEmail))
{
parameters.AddRange(
[
new KeyValuePair<string, string>("email", sendEmail),
new KeyValuePair<string, string>("email_otp", emailOtp)
]);
}
return new FormUrlEncodedContent(parameters);
}
}

View File

@@ -0,0 +1,45 @@
using Bit.Core.Auth.IdentityServer;
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.Identity.IdentityServer.Enums;
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
using Duende.IdentityModel;
namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess;
public static class SendAccessTestUtilities
{
public static FormUrlEncodedContent CreateTokenRequestBody(
Guid sendId,
string email = null,
string emailOtp = null,
string password = null)
{
var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
var parameters = new List<KeyValuePair<string, string>>
{
new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess),
new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send),
new(SendAccessConstants.TokenRequest.SendId, sendIdBase64),
new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess),
new("device_type", "10")
};
if (!string.IsNullOrEmpty(email))
{
parameters.Add(new KeyValuePair<string, string>(SendAccessConstants.TokenRequest.Email, email));
}
if (!string.IsNullOrEmpty(emailOtp))
{
parameters.Add(new KeyValuePair<string, string>(SendAccessConstants.TokenRequest.Otp, emailOtp));
}
if (!string.IsNullOrEmpty(password))
{
parameters.Add(new KeyValuePair<string, string>(SendAccessConstants.TokenRequest.ClientB64HashedPassword, password));
}
return new FormUrlEncodedContent(parameters);
}
}

View File

@@ -1,28 +1,16 @@
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Auth.IdentityServer;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
using Bit.Core.Utilities;
using Bit.Identity.IdentityServer.Enums;
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
using Bit.IntegrationTestCommon.Factories;
using Duende.IdentityModel;
using NSubstitute;
using Xunit;
namespace Bit.Identity.IntegrationTest.RequestValidation;
namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess;
public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture<IdentityApplicationFactory>
public class SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture<IdentityApplicationFactory>
{
private readonly IdentityApplicationFactory _factory;
public SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task SendAccess_EmailOtpProtectedSend_MissingEmail_ReturnsInvalidRequest()
{
@@ -43,7 +31,7 @@ public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture<Identi
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId); // No email
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // No email
// Act
var response = await client.PostAsync("/connect/token", requestBody);
@@ -87,7 +75,7 @@ public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture<Identi
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId, sendEmail: email); // Email but no OTP
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId, email: email); // Email but no OTP
// Act
var response = await client.PostAsync("/connect/token", requestBody);
@@ -130,7 +118,7 @@ public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture<Identi
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId, sendEmail: email, emailOtp: otp);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId, email: email, emailOtp: otp);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
@@ -174,7 +162,7 @@ public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture<Identi
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId, sendEmail: email, emailOtp: invalidOtp);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId, email: email, emailOtp: invalidOtp);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
@@ -216,7 +204,7 @@ public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture<Identi
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId, sendEmail: email); // Email but no OTP
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId, email: email); // Email but no OTP
// Act
var response = await client.PostAsync("/connect/token", requestBody);
@@ -225,32 +213,4 @@ public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture<Identi
var content = await response.Content.ReadAsStringAsync();
Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content);
}
private static FormUrlEncodedContent CreateTokenRequestBody(Guid sendId,
string sendEmail = null, string emailOtp = null)
{
var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
var parameters = new List<KeyValuePair<string, string>>
{
new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess),
new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send ),
new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess),
new("deviceType", ((int)DeviceType.FirefoxBrowser).ToString()),
new(SendAccessConstants.TokenRequest.SendId, sendIdBase64)
};
if (!string.IsNullOrEmpty(sendEmail))
{
parameters.Add(new KeyValuePair<string, string>(
SendAccessConstants.TokenRequest.Email, sendEmail));
}
if (!string.IsNullOrEmpty(emailOtp))
{
parameters.Add(new KeyValuePair<string, string>(
SendAccessConstants.TokenRequest.Otp, emailOtp));
}
return new FormUrlEncodedContent(parameters);
}
}

View File

@@ -0,0 +1,168 @@
using Bit.Core.Services;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
using Bit.Core.Utilities;
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
using Bit.IntegrationTestCommon.Factories;
using Duende.IdentityModel;
using NSubstitute;
using Xunit;
namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess;
public class SendNeverAuthenticateRequestValidatorIntegrationTests(
IdentityApplicationFactory _factory) : IClassFixture<IdentityApplicationFactory>
{
/// <summary>
/// To support the static hashing function <see cref="EnumerationProtectionHelpers.GetIndexForInputHash"/> theses GUIDs and Key must be hardcoded
/// </summary>
private static readonly string _testHashKey = "test-key-123456789012345678901234567890";
// These Guids are static and ensure the correct index for each error type
private static readonly Guid _invalidSendGuid = Guid.Parse("1b35fbf3-a14a-4d48-82b7-2adc34fdae6f");
private static readonly Guid _emailSendGuid = Guid.Parse("bc8e2ef5-a0bd-44d2-bdb7-5902be6f5c41");
private static readonly Guid _passwordSendGuid = Guid.Parse("da36fa27-f0e8-4701-a585-d3d8c2f67c4b");
[Fact]
public async Task SendAccess_NeverAuthenticateSend_NoParameters_ReturnsInvalidSendId()
{
// Arrange
var client = ConfigureTestHttpClient(_invalidSendGuid);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_invalidSendGuid);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
// Assert
var content = await response.Content.ReadAsStringAsync();
Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content);
var expectedError = SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId;
Assert.Contains(expectedError, content);
}
[Fact]
public async Task SendAccess_NeverAuthenticateSend_ReturnsEmailRequired()
{
// Arrange
var client = ConfigureTestHttpClient(_emailSendGuid);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
// Assert
var content = await response.Content.ReadAsStringAsync();
// should be invalid grant
Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content);
// Try to compel the invalid email error
var expectedError = SendAccessConstants.EmailOtpValidatorResults.EmailRequired;
Assert.Contains(expectedError, content);
}
[Fact]
public async Task SendAccess_NeverAuthenticateSend_WithEmail_ReturnsEmailInvalid()
{
// Arrange
var email = "test@example.com";
var client = ConfigureTestHttpClient(_emailSendGuid);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid, email: email);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
// Assert
var content = await response.Content.ReadAsStringAsync();
// should be invalid grant
Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content);
// Try to compel the invalid email error
var expectedError = SendAccessConstants.EmailOtpValidatorResults.EmailInvalid;
Assert.Contains(expectedError, content);
}
[Fact]
public async Task SendAccess_NeverAuthenticateSend_ReturnsPasswordRequired()
{
// Arrange
var client = ConfigureTestHttpClient(_passwordSendGuid);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_passwordSendGuid);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
// Assert
var content = await response.Content.ReadAsStringAsync();
Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content);
var expectedError = SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired;
Assert.Contains(expectedError, content);
}
[Fact]
public async Task SendAccess_NeverAuthenticateSend_WithPassword_ReturnsPasswordInvalid()
{
// Arrange
var password = "test-password-hash";
var client = ConfigureTestHttpClient(_passwordSendGuid);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_passwordSendGuid, password: password);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
// Assert
var content = await response.Content.ReadAsStringAsync();
Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content);
var expectedError = SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch;
Assert.Contains(expectedError, content);
}
[Fact]
public async Task SendAccess_NeverAuthenticateSend_ConsistentResponse_SameSendId()
{
// Arrange
var client = ConfigureTestHttpClient(_emailSendGuid);
var requestBody1 = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid);
var requestBody2 = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid);
// Act
var response1 = await client.PostAsync("/connect/token", requestBody1);
var response2 = await client.PostAsync("/connect/token", requestBody2);
// Assert
var content1 = await response1.Content.ReadAsStringAsync();
var content2 = await response2.Content.ReadAsStringAsync();
Assert.Equal(content1, content2);
}
private HttpClient ConfigureTestHttpClient(Guid sendId)
{
_factory.UpdateConfiguration(
"globalSettings:sendDefaultHashKey", _testHashKey);
return _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
services.AddSingleton(featureService);
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
sendAuthQuery.GetAuthenticationMethod(sendId)
.Returns(new NeverAuthenticate());
services.AddSingleton(sendAuthQuery);
});
}).CreateClient();
}
}

View File

@@ -1,28 +1,17 @@
using Bit.Core.Auth.IdentityServer;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Sends;
using Bit.Core.KeyManagement.Sends;
using Bit.Core.Services;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
using Bit.Core.Utilities;
using Bit.Identity.IdentityServer.Enums;
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
using Bit.IntegrationTestCommon.Factories;
using Duende.IdentityModel;
using NSubstitute;
using Xunit;
namespace Bit.Identity.IntegrationTest.RequestValidation;
namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess;
public class SendPasswordRequestValidatorIntegrationTests : IClassFixture<IdentityApplicationFactory>
public class SendPasswordRequestValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture<IdentityApplicationFactory>
{
private readonly IdentityApplicationFactory _factory;
public SendPasswordRequestValidatorIntegrationTests(IdentityApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task SendAccess_PasswordProtectedSend_ValidPassword_ReturnsAccessToken()
{
@@ -54,7 +43,7 @@ public class SendPasswordRequestValidatorIntegrationTests : IClassFixture<Identi
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId, clientPasswordHash);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId, password: clientPasswordHash);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
@@ -95,7 +84,7 @@ public class SendPasswordRequestValidatorIntegrationTests : IClassFixture<Identi
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId, wrongClientPasswordHash);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId, password: wrongClientPasswordHash);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
@@ -131,7 +120,7 @@ public class SendPasswordRequestValidatorIntegrationTests : IClassFixture<Identi
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId); // No password
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // No password
// Act
var response = await client.PostAsync("/connect/token", requestBody);
@@ -176,7 +165,7 @@ public class SendPasswordRequestValidatorIntegrationTests : IClassFixture<Identi
});
}).CreateClient();
var requestBody = CreateTokenRequestBody(sendId, string.Empty);
var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId, string.Empty);
// Act
var response = await client.PostAsync("/connect/token", requestBody);
@@ -186,24 +175,4 @@ public class SendPasswordRequestValidatorIntegrationTests : IClassFixture<Identi
Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content);
Assert.Contains($"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required", content);
}
private static FormUrlEncodedContent CreateTokenRequestBody(Guid sendId, string passwordHash = null)
{
var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
var parameters = new List<KeyValuePair<string, string>>
{
new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess),
new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send),
new(SendAccessConstants.TokenRequest.SendId, sendIdBase64),
new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess),
new("deviceType", "10")
};
if (passwordHash != null)
{
parameters.Add(new KeyValuePair<string, string>(SendAccessConstants.TokenRequest.ClientB64HashedPassword, passwordHash));
}
return new FormUrlEncodedContent(parameters);
}
}

Some files were not shown because too many files have changed in this diff Show More