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:
9
.github/workflows/repository-management.yml
vendored
9
.github/workflows/repository-management.yml
vendored
@@ -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
72
CLAUDE.md
Normal 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)
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
26
src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs
Normal file
26
src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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)
|
||||
:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
{
|
||||
@@ -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
|
||||
{
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
15
src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs
Normal file
15
src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
38
src/Core/KeyManagement/Models/Data/KdfSettings.cs
Normal file
38
src/Core/KeyManagement/Models/Data/KdfSettings.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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!";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
36
src/Core/Utilities/EnumerationProtectionHelpers.cs
Normal file
36
src/Core/Utilities/EnumerationProtectionHelpers.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
26
src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs
Normal file
26
src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
36
test/Api.Test/Utilities/KdfSettingsValidatorTests.cs
Normal file
36
test/Api.Test/Utilities/KdfSettingsValidatorTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
322
test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs
Normal file
322
test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
230
test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs
Normal file
230
test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs
Normal 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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user