From 26e574e8d75f0dda47bfcb9ae12b11783096ade1 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Wed, 17 Sep 2025 17:14:00 -0400 Subject: [PATCH 01/31] Auth/pm 25453/support n users auth request (#6347) * fix(pending-auth-request-view): [PM-25453] Bugfix Auth Requests Multiple Users Same Device - fixed view to allow for multiple users for each device when partitioning for the auth request view. --- .../Views/AuthRequestPendingDetailsView.sql | 2 +- ...00_UpdateAuthRequestPendingDetailsView.sql | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 util/Migrator/DbScripts/2025-09-16_00_UpdateAuthRequestPendingDetailsView.sql diff --git a/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql b/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql index d0433bca09..16f8a51195 100644 --- a/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql +++ b/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql @@ -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] diff --git a/util/Migrator/DbScripts/2025-09-16_00_UpdateAuthRequestPendingDetailsView.sql b/util/Migrator/DbScripts/2025-09-16_00_UpdateAuthRequestPendingDetailsView.sql new file mode 100644 index 0000000000..21b51387af --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-16_00_UpdateAuthRequestPendingDetailsView.sql @@ -0,0 +1,42 @@ +CREATE OR ALTER VIEW [dbo].[AuthRequestPendingDetailsView] +AS + WITH + PendingRequests + AS + ( + SELECT + [AR].*, + [D].[Id] AS [DeviceId], + 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] + AND [D].[UserId] = [AR].[UserId] + WHERE [AR].[Type] IN (0, 1) -- 0 = AuthenticateAndUnlock, 1 = Unlock + ) +SELECT + [PR].[Id], + [PR].[UserId], + [PR].[OrganizationId], + [PR].[Type], + [PR].[RequestDeviceIdentifier], + [PR].[RequestDeviceType], + [PR].[RequestIpAddress], + [PR].[RequestCountryName], + [PR].[ResponseDeviceId], + [PR].[AccessCode], + [PR].[PublicKey], + [PR].[Key], + [PR].[MasterPasswordHash], + [PR].[Approved], + [PR].[CreationDate], + [PR].[ResponseDate], + [PR].[AuthenticationDate], + [PR].[DeviceId] +FROM [PendingRequests] [PR] +WHERE [PR].[rn] = 1 + AND [PR].[Approved] IS NULL -- since we only want pending requests we only want the most recent that is also approved = null +GO + +EXECUTE sp_refreshsqlmodule N'[dbo].[AuthRequestPendingDetailsView]' +GO From e46365ac206be26c393c8d0198bbd8aa8c5e24c7 Mon Sep 17 00:00:00 2001 From: Ben Brooks <56796209+bensbits91@users.noreply.github.com> Date: Wed, 17 Sep 2025 14:36:00 -0700 Subject: [PATCH 02/31] feat(policies): add URI Match Defaults organizational policy (#6294) * feat(policies): add URI Match Defaults organizational policy Signed-off-by: Ben Brooks * feat(policies): remove unecessary model and org feature Signed-off-by: Ben Brooks --------- Signed-off-by: Ben Brooks --- src/Core/AdminConsole/Enums/PolicyType.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index ab39e543f8..f038faaf0b 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -18,6 +18,7 @@ public enum PolicyType : byte FreeFamiliesSponsorshipPolicy = 13, RemoveUnlockWithPin = 14, RestrictedItemTypesPolicy = 15, + UriMatchDefaults = 16, } public static class PolicyTypeExtensions @@ -46,6 +47,7 @@ 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", }; } } From 780400fcf9ed8a7f4650e01ac825a9a480e49121 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:50:36 +1000 Subject: [PATCH 03/31] [PM-25138] Reduce db locking when creating default collections (#6308) * Use single method for default collection creation * Use GenerateComb to create sequential guids * Pre-sort data for SqlBulkCopy * Add SqlBulkCopy options per dbops recommendations --- .../ConfirmOrganizationUserCommand.cs | 62 ++++++++++++++++--- ...anizationDataOwnershipPolicyRequirement.cs | 5 ++ .../Repositories/ICollectionRepository.cs | 9 ++- src/Core/Utilities/CoreHelpers.cs | 7 ++- .../Helpers/BulkResourceCreationService.cs | 33 ++++++++-- .../Repositories/CollectionRepository.cs | 10 +-- .../Utilities/SqlGuidHelpers.cs | 26 ++++++++ .../Repositories/CollectionRepository.cs | 11 ++-- .../ConfirmOrganizationUserCommandTests.cs | 42 ++++++++++--- 9 files changed, 172 insertions(+), 33 deletions(-) create mode 100644 src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 83ec244c47..2fbe6be5c6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -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 } /// - /// 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. + /// + /// The organization user who has just been confirmed. + /// The encrypted default user collection name. + 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(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]); + } + + /// + /// Creates default collections for multiple users if required by the Organization Data Ownership policy. /// /// The organization ID. /// The confirmed organization users. /// The encrypted default user collection name. - private async Task HandleConfirmationSideEffectsAsync(Guid organizationId, + private async Task CreateManyDefaultCollectionsAsync(Guid organizationId, IEnumerable confirmedOrganizationUsers, string defaultUserCollectionName) { if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) @@ -266,7 +311,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - var policyEligibleOrganizationUserIds = await _policyRequirementQuery.GetManyByOrganizationIdAsync(organizationId); + var policyEligibleOrganizationUserIds = + await _policyRequirementQuery.GetManyByOrganizationIdAsync(organizationId); var eligibleOrganizationUserIds = confirmedOrganizationUsers .Where(ou => policyEligibleOrganizationUserIds.Contains(ou.Id)) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs index cb72a51850..28d6614dcb 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs @@ -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) diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index ca3e52751c..f86147ca7d 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -63,5 +63,12 @@ public interface ICollectionRepository : IRepository Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable collectionIds, IEnumerable users, IEnumerable groups); - Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName); + /// + /// Creates default user collections for the specified organization users if they do not already have one. + /// + /// The Organization ID. + /// The Organization User IDs to create default collections for. + /// The encrypted string to use as the default collection name. + /// + Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName); } diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index 813eb6d1aa..5acdc63489 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -41,9 +41,12 @@ public static class CoreHelpers }; /// - /// 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 for any database IDs. /// + /// + /// ref: https://github.com/nhibernate/nhibernate-core/blob/master/src/NHibernate/Id/GuidCombGenerator.cs + /// /// A comb Guid. public static Guid GenerateComb() => GenerateComb(Guid.NewGuid(), DateTime.UtcNow); diff --git a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs index 3610c1c484..5a743ba028 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs @@ -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 collectionUsers, string errorMessage = _defaultErrorMessage) + public static async Task CreateCollectionsUsersAsync(SqlConnection connection, SqlTransaction transaction, + IEnumerable 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 collections, string errorMessage = _defaultErrorMessage) + public static async Task CreateCollectionsAsync(SqlConnection connection, SqlTransaction transaction, + IEnumerable 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); } diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index ad00ac7086..c2a59f75aa 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -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, ICollectionRep } } - public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName) + public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) { - if (!affectedOrgUserIds.Any()) + organizationUserIds = organizationUserIds.ToList(); + if (!organizationUserIds.Any()) { return; } @@ -340,7 +342,7 @@ public class CollectionRepository : Repository, 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, ICollectionRep foreach (var orgUserId in missingDefaultCollectionUserIds) { - var collectionId = Guid.NewGuid(); + var collectionId = CoreHelpers.GenerateComb(); collections.Add(new Collection { diff --git a/src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs b/src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs new file mode 100644 index 0000000000..fc548e2ff0 --- /dev/null +++ b/src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs @@ -0,0 +1,26 @@ +using System.Data.SqlTypes; + +namespace Bit.Infrastructure.Dapper.Utilities; + +public static class SqlGuidHelpers +{ + /// + /// Sorts the source IEnumerable by the specified Guid property using the 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 + /// + public static IOrderedEnumerable OrderBySqlGuid( + this IEnumerable source, + Func keySelector) + { + return source.OrderBy(x => new SqlGuid(keySelector(x))); + } + + /// + public static IOrderedEnumerable ThenBySqlGuid( + this IOrderedEnumerable source, + Func keySelector) + { + return source.ThenBy(x => new SqlGuid(keySelector(x))); + } +} diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 021b5bcf16..5aa156d1f8 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -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 affectedOrgUserIds, string defaultCollectionName) + public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) { - if (!affectedOrgUserIds.Any()) + organizationUserIds = organizationUserIds.ToList(); + if (!organizationUserIds.Any()) { return; } @@ -804,8 +806,7 @@ public class CollectionRepository : Repository().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() - .GetManyByOrganizationIdAsync(organization.Id) - .Returns(new List { orgUser.Id }); + .GetAsync(orgUser.UserId!.Value) + .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Enabled, [policyDetails])); await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName); await sutProvider.GetDependency() .Received(1) - .UpsertDefaultCollectionsAsync( - organization.Id, - Arg.Is>(ids => ids.Contains(orgUser.Id)), - collectionName); + .CreateAsync( + Arg.Is(c => + c.Name == collectionName && + c.OrganizationId == organization.Id && + c.Type == CollectionType.DefaultUserCollection), + Arg.Any>(), + Arg.Is>(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 sutProvider) { org.PlanType = PlanType.EnterpriseAnnually; @@ -523,9 +538,18 @@ public class ConfirmOrganizationUserCommandTests sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); sutProvider.GetDependency().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() - .GetManyByOrganizationIdAsync(org.Id) - .Returns(new List { orgUser.UserId!.Value }); + .GetAsync(orgUser.UserId!.Value) + .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [policyDetails])); await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName); From 866a572d26c842cbddeffab9cc0f3378db90ee80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Thu, 18 Sep 2025 13:41:19 +0200 Subject: [PATCH 04/31] Enable custom IDs for bindings (#6340) * Enable custom IDs for bindings * Remove description --- src/Api/Controllers/CollectionsController.cs | 2 +- src/Api/Controllers/DevicesController.cs | 2 +- src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs | 4 ---- src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs | 7 ++++--- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index f037ab7034..b3542cfde2 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -199,7 +199,7 @@ public class CollectionsController : Controller [HttpPost("{id}")] [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] - public async Task Post(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model) + public async Task PostPut(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model) { return await Put(orgId, id, model); } diff --git a/src/Api/Controllers/DevicesController.cs b/src/Api/Controllers/DevicesController.cs index 1f2cda9cc4..d54b3c7b8c 100644 --- a/src/Api/Controllers/DevicesController.cs +++ b/src/Api/Controllers/DevicesController.cs @@ -115,7 +115,7 @@ public class DevicesController : Controller [HttpPost("{id}")] [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] - public async Task Post(string id, [FromBody] DeviceRequestModel model) + public async Task PostPut(string id, [FromBody] DeviceRequestModel model) { return await Put(id, model); } diff --git a/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs index fba8b17078..68c0b5145a 100644 --- a/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs +++ b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs @@ -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)); diff --git a/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs b/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs index 60803705d6..d57ee72d48 100644 --- a/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs +++ b/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs @@ -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(); + 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(); // These two filters require debug symbols/git, so only add them in development mode if (environment.IsDevelopment()) From c93c3464732c93c9be593a3a55b032c029c4bd6f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:50:24 +0200 Subject: [PATCH 05/31] [deps] Platform: Update LaunchDarkly.ServerSdk to 8.10.1 (#6210) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index a7db90e892..e76af0f8ef 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -59,7 +59,7 @@ - + From 9d3d35e0bf3f6f15d7705ffa12069bc427a600e0 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Thu, 18 Sep 2025 11:22:22 -0500 Subject: [PATCH 06/31] removing status from org name. (#6350) --- src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs index e43d5a72bd..6e5504c73a 100644 --- a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs @@ -31,7 +31,7 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel ? 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, + OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false), Email = WebUtility.UrlEncode(orgUser.Email), OrganizationId = orgUser.OrganizationId.ToString(), OrganizationUserId = orgUser.Id.ToString(), From 7e4dac98378e80e7fd2f60a510c877092889c30f Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:08:47 -0500 Subject: [PATCH 07/31] chore: remove FF, references, and restructure code, refs PM-24373 (#6353) --- .../SendOrganizationInvitesCommand.cs | 6 +--- src/Core/Constants.cs | 1 - .../Models/Mail/OrganizationInvitesInfo.cs | 6 ---- .../Mail/OrganizationUserInvitedViewModel.cs | 34 ------------------- .../Implementations/HandlebarsMailService.cs | 26 +++++--------- 5 files changed, 10 insertions(+), 63 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs index 69b968d438..cd5066d11b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -22,8 +22,7 @@ public class SendOrganizationInvitesCommand( IPolicyRepository policyRepository, IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, IDataProtectorTokenFactory 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 ); } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 43bba121df..025871440f 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -135,7 +135,6 @@ public static class FeatureFlagKeys 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"; diff --git a/src/Core/Models/Mail/OrganizationInvitesInfo.cs b/src/Core/Models/Mail/OrganizationInvitesInfo.cs index c31e00c184..af53f23a8a 100644 --- a/src/Core/Models/Mail/OrganizationInvitesInfo.cs +++ b/src/Core/Models/Mail/OrganizationInvitesInfo.cs @@ -15,7 +15,6 @@ public class OrganizationInvitesInfo bool orgSsoLoginRequiredPolicyEnabled, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> orgUserTokenPairs, Dictionary 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 OrgUserHasExistingUserDict { get; } diff --git a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs index 6e5504c73a..669887c4b6 100644 --- a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs @@ -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), - 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!"; diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 89a613b7ed..9728c2e727 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -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); From d2c2ae5b4d694087d0873b553820808fdf05add9 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:30:05 -0700 Subject: [PATCH 08/31] fix(invalid-auth-request-approvals): Auth/[PM-3387] Better Error Handling for Invalid Auth Request Approval (#6264) If a user approves an invalid auth request, on the Requesting Device they currently they get stuck on the `LoginViaAuthRequestComponent` with a spinning wheel. This PR makes it so that when an Approving Device attempts to approve an invalid auth request, the Approving Device receives an error toast and the `UpdateAuthRequestAsync()` operation is blocked. --- .../Controllers/AuthRequestsController.cs | 30 +++++ .../AuthRequestsControllerTests.cs | 114 +++++++++++++++++- 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index c62b817905..e4a9027f20 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -102,7 +102,37 @@ public class AuthRequestsController( public async Task 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."); + } + } } diff --git a/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs index 1b8e7aba8e..fc7eb0d93b 100644 --- a/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs @@ -222,7 +222,7 @@ public class AuthRequestsControllerTests } [Theory, BitAutoData] - public async Task Put_ReturnsAuthRequest( + public async Task Put_WithRequestNotApproved_ReturnsAuthRequest( SutProvider 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() .GetProperUserId(Arg.Any()) @@ -248,6 +249,117 @@ public class AuthRequestsControllerTests Assert.IsType(result); } + [Theory, BitAutoData] + public async Task Put_WithApprovedRequest_ValidatesAndReturnsAuthRequest( + SutProvider sutProvider, + User user, + AuthRequestUpdateRequestModel requestModel, + AuthRequest currentAuthRequest, + AuthRequest updatedAuthRequest, + List 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() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + // Setup validation dependencies + sutProvider.GetDependency() + .GetAuthRequestAsync(currentAuthRequest.Id, user.Id) + .Returns(currentAuthRequest); + + sutProvider.GetDependency() + .GetManyPendingAuthRequestByUserId(user.Id) + .Returns(pendingRequests); + + sutProvider.GetDependency() + .UpdateAuthRequestAsync(currentAuthRequest.Id, user.Id, requestModel) + .Returns(updatedAuthRequest); + + // Act + var result = await sutProvider.Sut + .Put(currentAuthRequest.Id, requestModel); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task Put_WithApprovedRequest_CurrentAuthRequestNotFound_ThrowsNotFoundException( + SutProvider sutProvider, + User user, + AuthRequestUpdateRequestModel requestModel, + Guid authRequestId) + { + // Arrange + requestModel.RequestApproved = true; // Approval triggers validation + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + // Current auth request not found + sutProvider.GetDependency() + .GetAuthRequestAsync(authRequestId, user.Id) + .Returns((AuthRequest)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.Put(authRequestId, requestModel)); + } + + [Theory, BitAutoData] + public async Task Put_WithApprovedRequest_NotMostRecentForDevice_ThrowsBadRequestException( + SutProvider sutProvider, + User user, + AuthRequestUpdateRequestModel requestModel, + AuthRequest currentAuthRequest, + List 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() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetAuthRequestAsync(currentAuthRequest.Id, user.Id) + .Returns(currentAuthRequest); + + sutProvider.GetDependency() + .GetManyPendingAuthRequestByUserId(user.Id) + .Returns(pendingRequests); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => 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 sutProvider) { sutProvider.GetDependency() From 14b307c15b6af5fc05a86110d3fea7b6ca548387 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:26:22 -0500 Subject: [PATCH 09/31] [PM-25205] Don't respond with a tax ID warning for US customers (#6310) * Don't respond with a Tax ID warning for US customers * Only show provider tax ID warning for non-US based providers --- .../Queries/GetProviderWarningsQuery.cs | 8 +- .../Queries/GetProviderWarningsQueryTests.cs | 79 +++- .../Queries/GetOrganizationWarningsQuery.cs | 6 + .../GetOrganizationWarningsQueryTests.cs | 441 +++++++++++++++++- 4 files changed, 496 insertions(+), 38 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs index 9392c285e0..cc77797307 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs @@ -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) diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs index f199c44924..a7f896ef7a 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs @@ -58,7 +58,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -90,7 +90,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -124,7 +124,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -158,7 +158,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -191,7 +191,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -219,7 +219,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -227,7 +227,7 @@ public class GetProviderWarningsQueryTests sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - 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 { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -260,7 +260,7 @@ public class GetProviderWarningsQueryTests sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - 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().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - 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().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - 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().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - 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().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - 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 { Data = [] }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + 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 sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer { TaxIds = new StripeList { 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); } } diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs index 312623ffa5..f33814f1cf 100644 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs @@ -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 diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs index eefda06149..5234d500d1 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs @@ -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().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().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().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().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 sutProvider) + { + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "US" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_FreeCustomer_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.Free; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_NotOwner_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .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 sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .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 sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + 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 sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + 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 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 { Data = new List { taxId } }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + 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 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 { Data = new List { taxId } }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + 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 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 { Data = new List { taxId } }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + 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 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 { Data = new List { taxId } }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { Country = "CA" } + } + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } } From 3ac3b8c8d984eaf5fee59be35bb2bd4cb0c8ac61 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:27:12 -0500 Subject: [PATCH 10/31] Remove FF (#6302) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 025871440f..cfc85f8164 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -168,7 +168,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"; From d384c0cfe60ec02226479d3cde200e4d785a7e50 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Fri, 19 Sep 2025 16:17:32 -0400 Subject: [PATCH 11/31] [PM-7730] Deprecate type-specific cipher properties in favor of opaque Data string (#6354) * Marked structured fields as obsolete and add Data field to the request model * Fixed lint issues * Deprecated properties * Changed to 1mb --- .../Models/Request/CipherRequestModel.cs | 74 +++++++++++++------ .../Models/Response/CipherResponseModel.cs | 26 +++++-- 2 files changed, 70 insertions(+), 30 deletions(-) diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index 467be6e356..b0589a62f9 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -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 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; } + + /// + /// JSON string containing cipher-specific data + /// + [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>(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; diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index 3e4e8da512..dfacc1a551 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -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(cipher.Data); cipherData = loginData; - Data = loginData; Login = new CipherLoginModel(loginData); break; case CipherType.SecureNote: var secureNoteData = JsonSerializer.Deserialize(cipher.Data); - Data = secureNoteData; cipherData = secureNoteData; SecureNote = new CipherSecureNoteModel(secureNoteData); break; case CipherType.Card: var cardData = JsonSerializer.Deserialize(cipher.Data); - Data = cardData; cipherData = cardData; Card = new CipherCardModel(cardData); break; case CipherType.Identity: var identityData = JsonSerializer.Deserialize(cipher.Data); - Data = identityData; cipherData = identityData; Identity = new CipherIdentityModel(identityData); break; case CipherType.SSHKey: var sshKeyData = JsonSerializer.Deserialize(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 Fields { get; set; } + + [Obsolete("Use Data instead.")] public IEnumerable PasswordHistory { get; set; } public IEnumerable Attachments { get; set; } public bool OrganizationUseTotp { get; set; } From dc2828291b0de886b36d2aebcd18d11ae8f32bab Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 22 Sep 2025 15:02:24 +0000 Subject: [PATCH 12/31] Bumped version to 2025.9.2 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 9038c8d95d..71303d3529 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.9.1 + 2025.9.2 Bit.$(MSBuildProjectName) enable From fe7e96eb6aeaa42ac2fe7af9c3bcf74258c52abf Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Mon, 22 Sep 2025 10:36:19 -0500 Subject: [PATCH 13/31] PM-25870 Activity tab feature flag (#6360) --- src/Core/Constants.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index cfc85f8164..f596cdefee 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -233,6 +233,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 GetAllKeys() { return typeof(FeatureFlagKeys).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) From 0b6b93048b4b57d51183e2aa4dc5a1e0e9a51f8a Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Mon, 22 Sep 2025 11:05:16 -0500 Subject: [PATCH 14/31] [PM-25373] Add feature flag (#6358) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index f596cdefee..96ee509db1 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -184,6 +184,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"; From 8c238ce08db8e5b227ebdc9a980b99445b7ed140 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Mon, 22 Sep 2025 13:46:35 -0400 Subject: [PATCH 15/31] fix: adjust permissions of repo management workflow (#6130) - Specify permissions needed for the repo_management job - Add required permissions (actions: read, contents: write, id-token: write, pull-requests: write) to the move_edd_db_scripts job --- .github/workflows/repository-management.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index ad80d5864c..67e1d8a926 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -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 From ed5e4271df54e54dc271905cc10efefb967eaaa7 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Mon, 22 Sep 2025 13:51:36 -0400 Subject: [PATCH 16/31] [PM-25123] Remove VerifyBankAsync Code (#6355) * refactor: remove VerifyBankAsync from interface and implementation * refactor: remove controller endpoint --- .../Controllers/OrganizationsController.cs | 12 ------ .../Services/IOrganizationService.cs | 1 - .../Implementations/OrganizationService.cs | 43 ------------------- 3 files changed, 56 deletions(-) diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 977b20bdfb..5494c5a90e 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -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) { diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 94df74afdf..f509ac8358 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -18,7 +18,6 @@ public interface IOrganizationService Task UpdateSubscription(Guid organizationId, int seatAdjustment, int? maxAutoscaleSeats); Task AutoAddSeatsAsync(Organization organization, int seatsToAdd); Task 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 UpdateCollectionManagementSettingsAsync(Guid organizationId, OrganizationCollectionManagementSettings settings); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 57eb4f51de..1b52ad8cff 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -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 { "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 { 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); From c6f5d5e36e830642c6e028732aa66cb6a9583ad2 Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Mon, 22 Sep 2025 15:39:15 -0400 Subject: [PATCH 17/31] [PM-25986] Add server side enum type for AutotypeDefaultSetting policy (#6356) * PM-25986 Add server side enum type for AutotypeDefaultSetting policy * Update PolicyType.cs remove space --- src/Core/AdminConsole/Enums/PolicyType.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index f038faaf0b..452fbcce01 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -19,6 +19,7 @@ public enum PolicyType : byte RemoveUnlockWithPin = 14, RestrictedItemTypesPolicy = 15, UriMatchDefaults = 16, + AutotypeDefaultSetting = 17, } public static class PolicyTypeExtensions @@ -48,6 +49,7 @@ public static class PolicyTypeExtensions PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN", PolicyType.RestrictedItemTypesPolicy => "Restricted item types", PolicyType.UriMatchDefaults => "URI match defaults", + PolicyType.AutotypeDefaultSetting => "Autotype default setting", }; } } From 3b54fea309fc9612389a77785fd7f5470366370a Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Tue, 23 Sep 2025 06:38:22 -0400 Subject: [PATCH 18/31] [PM-22696] send enumeration protection (#6352) * feat: add static enumeration helper class * test: add enumeration helper class unit tests * feat: implement NeverAuthenticateValidator * test: unit and integration tests SendNeverAuthenticateValidator * test: use static class for common integration test setup for Send Access unit and integration tests * test: update tests to use static helper --- src/Core/Settings/GlobalSettings.cs | 4 + .../Utilities/EnumerationProtectionHelpers.cs | 36 +++ .../SendAccess/SendAccessConstants.cs | 22 +- .../SendAccess/SendAccessGrantValidator.cs | 37 +-- .../SendNeverAuthenticateRequestValidator.cs | 87 ++++++ .../Utilities/ServiceCollectionExtensions.cs | 1 + .../EnumerationProtectionHelpersTests.cs | 230 ++++++++++++++ ...endAccessGrantValidatorIntegrationTests.cs | 53 +--- .../SendAccess/SendAccessTestUtilities.cs | 45 +++ ...EmailOtpReqestValidatorIntegrationTests.cs | 54 +--- ...ndNeverAuthenticateRequestValidatorTest.cs | 168 +++++++++++ ...asswordRequestValidatorIntegrationTests.cs | 45 +-- .../ResourceOwnerPasswordValidatorTests.cs | 2 +- .../SendAccessGrantValidatorTests.cs | 63 +--- .../SendAccess/SendAccessTestUtilities.cs | 50 ++++ .../SendAccess/SendConstantsSnapshotTests.cs | 6 +- .../SendEmailOtpRequestValidatorTests.cs | 49 +-- .../SendNeverAuthenticateValidatorTests.cs | 280 ++++++++++++++++++ .../SendPasswordRequestValidatorTests.cs | 47 +-- 19 files changed, 989 insertions(+), 290 deletions(-) create mode 100644 src/Core/Utilities/EnumerationProtectionHelpers.cs create mode 100644 src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs create mode 100644 test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs rename test/Identity.IntegrationTest/RequestValidation/{ => SendAccess}/SendAccessGrantValidatorIntegrationTests.cs (81%) create mode 100644 test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs rename test/Identity.IntegrationTest/RequestValidation/{ => SendAccess}/SendEmailOtpReqestValidatorIntegrationTests.cs (79%) create mode 100644 test/Identity.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs rename test/Identity.IntegrationTest/RequestValidation/{ => SendAccess}/SendPasswordRequestValidatorIntegrationTests.cs (80%) rename test/Identity.IntegrationTest/RequestValidation/{ => VaultAccess}/ResourceOwnerPasswordValidatorTests.cs (99%) create mode 100644 test/Identity.Test/IdentityServer/SendAccess/SendAccessTestUtilities.cs create mode 100644 test/Identity.Test/IdentityServer/SendAccess/SendNeverAuthenticateValidatorTests.cs diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index c6c96cffb9..107fd29236 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -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; } + /// + /// This Hash Key is used to prevent enumeration attacks against the Send Access feature. + /// + public virtual string SendDefaultHashKey { get; set; } public virtual string PricingUri { get; set; } public string BuildExternalUri(string explicitValue, string name) diff --git a/src/Core/Utilities/EnumerationProtectionHelpers.cs b/src/Core/Utilities/EnumerationProtectionHelpers.cs new file mode 100644 index 0000000000..b27c36e03a --- /dev/null +++ b/src/Core/Utilities/EnumerationProtectionHelpers.cs @@ -0,0 +1,36 @@ +using System.Text; + +namespace Bit.Core.Utilities; + +public static class EnumerationProtectionHelpers +{ + /// + /// 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. + /// + /// Key used to derive the HMAC hash. Use a different key for each usage for optimal security + /// The string to derive an index result + /// The range of possible index values + /// An int between 0 and range - 1 + 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; + } + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs index 17ec387411..1f5bfba244 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs @@ -34,18 +34,18 @@ public static class SendAccessConstants public const string Otp = "otp"; } - public static class GrantValidatorResults + public static class SendIdGuidValidatorResults { /// - /// The sendId is valid and the request is well formed. Not returned in any response. + /// The in the request is a valid GUID and the request is well formed. Not returned in any response. /// public const string ValidSendGuid = "valid_send_guid"; /// - /// The sendId is missing from the request. + /// The is missing from the request. /// public const string SendIdRequired = "send_id_required"; /// - /// The sendId is invalid, does not match a known send. + /// The is invalid, does not match a known send. /// public const string InvalidSendId = "send_id_invalid"; } @@ -53,11 +53,11 @@ public static class SendAccessConstants public static class PasswordValidatorResults { /// - /// The passwordHashB64 does not match the send's password hash. + /// The does not match the send's password hash. /// public const string RequestPasswordDoesNotMatch = "password_hash_b64_invalid"; /// - /// The passwordHashB64 is missing from the request. + /// The is missing from the request. /// 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}"; } + + /// + /// We use these static strings to help guide the enumeration protection logic. + /// + public static class EnumerationProtection + { + public const string Guid = "guid"; + public const string Password = "password"; + public const string Email = "email"; + } } diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs index d9ae946d16..101c6952f3 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs @@ -13,21 +13,19 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; public class SendAccessGrantValidator( ISendAuthenticationQuery _sendAuthenticationQuery, + ISendAuthenticationMethodValidator _sendNeverAuthenticateValidator, ISendAuthenticationMethodValidator _sendPasswordRequestValidator, ISendAuthenticationMethodValidator _sendEmailOtpRequestValidator, - IFeatureService _featureService) -: IExtensionGrantValidator + IFeatureService _featureService) : IExtensionGrantValidator { string IExtensionGrantValidator.GrantType => CustomGrantTypes.SendAccess; - private static readonly Dictionary - _sendGrantValidatorErrorDescriptions = new() + private static readonly Dictionary _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); } } /// /// Builds an error result for the specified error type. /// - /// This error is a constant string from + /// This error is a constant string from /// The error result. 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), diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs new file mode 100644 index 0000000000..36e033360f --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs @@ -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; + +/// +/// 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. +/// +/// We need access to a hash key to generate the error index. +public class SendNeverAuthenticateRequestValidator(GlobalSettings globalSettings) : ISendAuthenticationMethodValidator +{ + private readonly string[] _errorOptions = + [ + SendAccessConstants.EnumerationProtection.Guid, + SendAccessConstants.EnumerationProtection.Password, + SendAccessConstants.EnumerationProtection.Email + ]; + + public Task 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 + { + { 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]; + } +} diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index 9d062e5c06..e9056d030e 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -29,6 +29,7 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient, SendPasswordRequestValidator>(); services.AddTransient, SendEmailOtpRequestValidator>(); + services.AddTransient, SendNeverAuthenticateRequestValidator>(); var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity); var identityServerBuilder = services diff --git a/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs b/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs new file mode 100644 index 0000000000..68ac8af5d0 --- /dev/null +++ b/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs @@ -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(() => + 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 +} diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs similarity index 81% rename from test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs rename to test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs index ca6417d49c..a6dd4b6b38 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs @@ -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 +public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture { - 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> - { - 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("email", sendEmail), - new KeyValuePair("email_otp", emailOtp) - ]); - } - - return new FormUrlEncodedContent(parameters); - } } diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs new file mode 100644 index 0000000000..7842bf6367 --- /dev/null +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs @@ -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> + { + 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(SendAccessConstants.TokenRequest.Email, email)); + } + + if (!string.IsNullOrEmpty(emailOtp)) + { + parameters.Add(new KeyValuePair(SendAccessConstants.TokenRequest.Otp, emailOtp)); + } + + if (!string.IsNullOrEmpty(password)) + { + parameters.Add(new KeyValuePair(SendAccessConstants.TokenRequest.ClientB64HashedPassword, password)); + } + + return new FormUrlEncodedContent(parameters); + } +} diff --git a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs similarity index 79% rename from test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs rename to test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs index 9a097cc061..3c4657653b 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs @@ -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 +public class SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture { - 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> - { - 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( - SendAccessConstants.TokenRequest.Email, sendEmail)); - } - - if (!string.IsNullOrEmpty(emailOtp)) - { - parameters.Add(new KeyValuePair( - SendAccessConstants.TokenRequest.Otp, emailOtp)); - } - - return new FormUrlEncodedContent(parameters); - } } diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs new file mode 100644 index 0000000000..a81b01a293 --- /dev/null +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs @@ -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 +{ + /// + /// To support the static hashing function theses GUIDs and Key must be hardcoded + /// + 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(); + featureService.IsEnabled(Arg.Any()).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + sendAuthQuery.GetAuthenticationMethod(sendId) + .Returns(new NeverAuthenticate()); + services.AddSingleton(sendAuthQuery); + }); + }).CreateClient(); + } +} diff --git a/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendPasswordRequestValidatorIntegrationTests.cs similarity index 80% rename from test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs rename to test/Identity.IntegrationTest/RequestValidation/SendAccess/SendPasswordRequestValidatorIntegrationTests.cs index 856ffe1f6e..5b03a298ed 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendPasswordRequestValidatorIntegrationTests.cs @@ -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 +public class SendPasswordRequestValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture { - 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> - { - 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(SendAccessConstants.TokenRequest.ClientB64HashedPassword, passwordHash)); - } - - return new FormUrlEncodedContent(parameters); - } } diff --git a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs b/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs similarity index 99% rename from test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs rename to test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs index 4d243906af..91123b3a60 100644 --- a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs @@ -12,7 +12,7 @@ using Bit.Test.Common.Helpers; using Microsoft.AspNetCore.Identity; using Xunit; -namespace Bit.Identity.IntegrationTest.RequestValidation; +namespace Bit.Identity.IntegrationTest.RequestValidation.VaultAccess; public class ResourceOwnerPasswordValidatorTests : IClassFixture { diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs index 017ad70354..59d8dee2e2 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs @@ -1,12 +1,8 @@ -using System.Collections.Specialized; -using Bit.Core; +using Bit.Core; using Bit.Core.Auth.Identity; -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.Test.Common.AutoFixture; @@ -81,7 +77,7 @@ public class SendAccessGrantValidatorTests var context = new ExtensionGrantValidationContext(); tokenRequest.GrantType = CustomGrantTypes.SendAccess; - tokenRequest.Raw = CreateTokenRequestBody(Guid.Empty); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(Guid.Empty); // To preserve the CreateTokenRequestBody method for more general usage we over write the sendId tokenRequest.Raw.Set(SendAccessConstants.TokenRequest.SendId, "invalid-guid-format"); @@ -118,7 +114,9 @@ public class SendAccessGrantValidatorTests public async Task ValidateAsync_NeverAuthenticateMethod_ReturnsInvalidGrant( [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, SutProvider sutProvider, - Guid sendId) + NeverAuthenticate neverAuthenticate, + Guid sendId, + GrantValidationResult expectedResult) { // Arrange var context = SetupTokenRequest( @@ -128,14 +126,20 @@ public class SendAccessGrantValidatorTests sutProvider.GetDependency() .GetAuthenticationMethod(sendId) - .Returns(new NeverAuthenticate()); + .Returns(neverAuthenticate); + + sutProvider.GetDependency>() + .ValidateRequestAsync(context, neverAuthenticate, sendId) + .Returns(expectedResult); // Act await sutProvider.Sut.ValidateAsync(context); // Assert - Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.Result.Error); - Assert.Equal($"{SendAccessConstants.TokenRequest.SendId} is invalid.", context.Result.ErrorDescription); + Assert.Equal(expectedResult, context.Result); + await sutProvider.GetDependency>() + .Received(1) + .ValidateRequestAsync(context, neverAuthenticate, sendId); } [Theory, BitAutoData] @@ -264,7 +268,7 @@ public class SendAccessGrantValidatorTests public void GrantType_ReturnsCorrectType() { // Arrange & Act - var validator = new SendAccessGrantValidator(null!, null!, null!, null!); + var validator = new SendAccessGrantValidator(null!, null!, null!, null!, null!); // Assert Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType); @@ -289,44 +293,9 @@ public class SendAccessGrantValidatorTests var context = new ExtensionGrantValidationContext(); request.GrantType = CustomGrantTypes.SendAccess; - request.Raw = CreateTokenRequestBody(sendId); + request.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); context.Request = request; return context; } - - private static NameValueCollection CreateTokenRequestBody( - Guid sendId, - string passwordHash = null, - string sendEmail = null, - string otpCode = null) - { - var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); - - var rawRequestParameters = new NameValueCollection - { - { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, - { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, - { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, - { "deviceType", ((int)DeviceType.FirefoxBrowser).ToString() }, - { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } - }; - - if (passwordHash != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, passwordHash); - } - - if (sendEmail != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail); - } - - if (otpCode != null && sendEmail != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode); - } - - return rawRequestParameters; - } } diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendAccessTestUtilities.cs b/test/Identity.Test/IdentityServer/SendAccess/SendAccessTestUtilities.cs new file mode 100644 index 0000000000..b05380902a --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccess/SendAccessTestUtilities.cs @@ -0,0 +1,50 @@ +using System.Collections.Specialized; +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.Test.IdentityServer.SendAccess; + +public static class SendAccessTestUtilities +{ + public static NameValueCollection CreateValidatedTokenRequest( + Guid sendId, + string sendEmail = null, + string otpCode = null, + params string[] passwordHash) + { + var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); + + var rawRequestParameters = new NameValueCollection + { + { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, + { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, + { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, + { "device_type", ((int)DeviceType.FirefoxBrowser).ToString() }, + { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } + }; + + if (sendEmail != null) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail); + } + + if (otpCode != null && sendEmail != null) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode); + } + + if (passwordHash != null && passwordHash.Length > 0) + { + foreach (var hash in passwordHash) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, hash); + } + } + + return rawRequestParameters; + } +} diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs index 95a0a6675b..96a097a53c 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs @@ -31,9 +31,9 @@ public class SendConstantsSnapshotTests public void GrantValidatorResults_Constants_HaveCorrectValues() { // Assert - Assert.Equal("valid_send_guid", SendAccessConstants.GrantValidatorResults.ValidSendGuid); - Assert.Equal("send_id_required", SendAccessConstants.GrantValidatorResults.SendIdRequired); - Assert.Equal("send_id_invalid", SendAccessConstants.GrantValidatorResults.InvalidSendId); + Assert.Equal("valid_send_guid", SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid); + Assert.Equal("send_id_required", SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired); + Assert.Equal("send_id_invalid", SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); } [Fact] diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs index 70a1585d8b..46f61cb333 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs @@ -1,12 +1,7 @@ -using System.Collections.Specialized; -using Bit.Core.Auth.Identity; +using Bit.Core.Auth.Identity; 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.Utilities; -using Bit.Identity.IdentityServer.Enums; using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -28,7 +23,7 @@ public class SendEmailOtpRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -61,8 +56,7 @@ public class SendEmailOtpRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); - var emailOTP = new EmailOtp(["user@test.dev"]); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -96,7 +90,7 @@ public class SendEmailOtpRequestValidatorTests string generatedToken) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -144,7 +138,7 @@ public class SendEmailOtpRequestValidatorTests string email) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -179,7 +173,7 @@ public class SendEmailOtpRequestValidatorTests string otp) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, otp); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email, otp); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -231,7 +225,7 @@ public class SendEmailOtpRequestValidatorTests string invalidOtp) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, invalidOtp); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email, invalidOtp); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -278,33 +272,4 @@ public class SendEmailOtpRequestValidatorTests // Assert Assert.NotNull(validator); } - - private static NameValueCollection CreateValidatedTokenRequest( - Guid sendId, - string sendEmail = null, - string otpCode = null) - { - var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); - - var rawRequestParameters = new NameValueCollection - { - { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, - { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, - { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, - { "device_type", ((int)DeviceType.FirefoxBrowser).ToString() }, - { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } - }; - - if (sendEmail != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail); - } - - if (otpCode != null && sendEmail != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode); - } - - return rawRequestParameters; - } } diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendNeverAuthenticateValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendNeverAuthenticateValidatorTests.cs new file mode 100644 index 0000000000..ae0434af83 --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccess/SendNeverAuthenticateValidatorTests.cs @@ -0,0 +1,280 @@ +using Bit.Core.Tools.Models.Data; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityModel; +using Duende.IdentityServer.Validation; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer.SendAccess; + +[SutProviderCustomize] +public class SendNeverAuthenticateRequestValidatorTests +{ + /// + /// To support the static hashing function theses GUIDs and Key must be hardcoded + /// + 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"); + + private static readonly NeverAuthenticate _authMethod = new(); + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_GuidErrorSelected_ReturnsInvalidSendId( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_invalidSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _invalidSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal(SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal( + SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmailErrorSelected_HasEmail_ReturnsEmailInvalid( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + string email) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_emailSendGuid, sendEmail: email); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _emailSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmailErrorSelected_NoEmail_ReturnsEmailRequired( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_emailSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _emailSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailRequired, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailRequired, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_PasswordErrorSelected_HasPassword_ReturnsPasswordDoesNotMatch( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + string password) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_passwordSendGuid, passwordHash: password); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _passwordSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_PasswordErrorSelected_NoPassword_ReturnsPasswordRequired( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_passwordSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _passwordSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_NullHashKey_UsesEmptyKey( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_invalidSendGuid); + var context = new ExtensionGrantValidationContext { Request = tokenRequest }; + sutProvider.GetDependency().SendDefaultHashKey = null; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _invalidSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Contains(result.ErrorDescription, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmptyHashKey_UsesEmptyKey( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_invalidSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = ""; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _invalidSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Contains(result.ErrorDescription, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_ConsistentBehavior_SameSendIdSameResult( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + Guid sendId) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = "consistent-test-key-123456789012345678901234567890"; + + // Act + var result1 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId); + var result2 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId); + + // Assert + Assert.Equal(result1.ErrorDescription, result2.ErrorDescription); + Assert.Equal(result1.Error, result2.Error); + + var customResponse1 = result1.CustomResponse as Dictionary; + var customResponse2 = result2.CustomResponse as Dictionary; + Assert.Equal(customResponse1[SendAccessConstants.SendAccessError], customResponse2[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_DifferentSendIds_CanReturnDifferentResults( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + Guid sendId1, + Guid sendId2) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId1); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = "different-test-key-123456789012345678901234567890"; + + // Act + var result1 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId1); + var result2 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId2); + + // Assert - Both should be errors + Assert.True(result1.IsError); + Assert.True(result2.IsError); + + // Both should have valid error types + var validErrors = new[] + { + SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, + SendAccessConstants.EmailOtpValidatorResults.EmailRequired, + SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired + }; + Assert.Contains(result1.ErrorDescription, validErrors); + Assert.Contains(result2.ErrorDescription, validErrors); + } + + [Fact] + public void Constructor_WithValidGlobalSettings_CreatesInstance() + { + // Arrange + var globalSettings = new Core.Settings.GlobalSettings + { + SendDefaultHashKey = "test-key-123456789012345678901234567890" + }; + + // Act + var validator = new SendNeverAuthenticateRequestValidator(globalSettings); + + // Assert + Assert.NotNull(validator); + } +} diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs index e77626d37b..460b033fa7 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs @@ -1,12 +1,7 @@ -using System.Collections.Specialized; -using Bit.Core.Auth.Identity; -using Bit.Core.Auth.IdentityServer; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.UserFeatures.SendAccess; -using Bit.Core.Enums; using Bit.Core.KeyManagement.Sends; using Bit.Core.Tools.Models.Data; -using Bit.Core.Utilities; -using Bit.Identity.IdentityServer.Enums; using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -28,7 +23,7 @@ public class SendPasswordRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); var context = new ExtensionGrantValidationContext { @@ -58,7 +53,7 @@ public class SendPasswordRequestValidatorTests string clientPasswordHash) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: clientPasswordHash); var context = new ExtensionGrantValidationContext { @@ -92,7 +87,7 @@ public class SendPasswordRequestValidatorTests string clientPasswordHash) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: clientPasswordHash); var context = new ExtensionGrantValidationContext { @@ -130,7 +125,7 @@ public class SendPasswordRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, string.Empty); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: string.Empty); var context = new ExtensionGrantValidationContext { @@ -163,7 +158,7 @@ public class SendPasswordRequestValidatorTests { // Arrange var whitespacePassword = " "; - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, whitespacePassword); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: whitespacePassword); var context = new ExtensionGrantValidationContext { @@ -196,7 +191,7 @@ public class SendPasswordRequestValidatorTests // Arrange var firstPassword = "first-password"; var secondPassword = "second-password"; - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, firstPassword, secondPassword); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: [firstPassword, secondPassword]); var context = new ExtensionGrantValidationContext { @@ -229,7 +224,7 @@ public class SendPasswordRequestValidatorTests string clientPasswordHash) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: clientPasswordHash); var context = new ExtensionGrantValidationContext { @@ -268,30 +263,4 @@ public class SendPasswordRequestValidatorTests // Assert Assert.NotNull(validator); } - - private static NameValueCollection CreateValidatedTokenRequest( - Guid sendId, - params string[] passwordHash) - { - var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); - - var rawRequestParameters = new NameValueCollection - { - { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, - { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, - { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, - { "device_type", ((int)DeviceType.FirefoxBrowser).ToString() }, - { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } - }; - - if (passwordHash != null && passwordHash.Length > 0) - { - foreach (var hash in passwordHash) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, hash); - } - } - - return rawRequestParameters; - } } From 0b4b605524432431485852024dc5b35e16edd001 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Tue, 23 Sep 2025 15:52:56 +0000 Subject: [PATCH 19/31] Bumped version to 2025.9.3 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 71303d3529..cd1e95d0bd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.9.2 + 2025.9.3 Bit.$(MSBuildProjectName) enable From 744f11733d838ae1d3a82fdc33ae1fcd368c21c0 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:07:42 -0400 Subject: [PATCH 20/31] Revert "Bumped version to 2025.9.3" (#6369) This reverts commit 0b4b605524432431485852024dc5b35e16edd001. --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index cd1e95d0bd..71303d3529 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.9.3 + 2025.9.2 Bit.$(MSBuildProjectName) enable From ff092a031eb0489672095a76c8f4d4564222b835 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 24 Sep 2025 05:10:46 +0900 Subject: [PATCH 21/31] [PM-23229] Add extra validation to kdf changes + authentication data + unlock data (#6121) * Added MasterPasswordUnlock to UserDecryptionOptions as part of identity response * Implement support for authentication data and unlock data in kdf change * Extract to kdf command and add tests * Fix namespace * Delete empty file * Fix build * Clean up tests * Fix tests * Add comments * Cleanup * Cleanup * Cleanup * Clean-up and fix build * Address feedback; force new parameters on KDF change request * Clean-up and add tests * Re-add logger * Update logger to interface * Clean up, remove Kdf Request Model * Remove kdf request model tests * Fix types in test * Address feedback to rename request model and re-add tests * Fix namespace * Move comments * Rename InnerKdfRequestModel to KdfRequestModel --------- Co-authored-by: Maciej Zieniuk --- .../Auth/Controllers/AccountsController.cs | 18 +- .../Request/Accounts/KdfRequestModel.cs | 25 -- ...sswordUnlockDataAndAuthenticationModel.cs} | 6 +- .../Request/Accounts/PasswordRequestModel.cs | 14 +- .../Models/Requests/KdfRequestModel.cs | 26 ++ ...rPasswordAuthenticationDataRequestModel.cs | 21 ++ .../MasterPasswordUnlockDataRequestModel.cs | 22 ++ .../Models/Requests/UnlockDataRequestModel.cs | 2 +- src/Core/Entities/User.cs | 5 + .../KeyManagement/Kdf/IChangeKdfCommand.cs | 15 + .../Kdf/Implementations/ChangeKdfCommand.cs | 94 +++++ ...eyManagementServiceCollectionExtensions.cs | 3 + .../KeyManagement/Models/Data/KdfSettings.cs | 38 +++ .../Data/MasterPasswordAuthenticationData.cs | 18 + ...sterPasswordUnlockAndAuthenticationData.cs | 34 ++ .../Models/Data/MasterPasswordUnlockData.cs | 28 +- .../Models/Data/RotateUserAccountKeysData.cs | 2 +- src/Core/Services/IUserService.cs | 2 - .../Services/Implementations/UserService.cs | 33 -- src/Core/Utilities/KdfSettingsValidator.cs | 6 + .../Controllers/AccountsControllerTests.cs | 61 +++- .../Request/MasterPasswordUnlockDataModel.cs | 6 +- .../Request/Accounts/KdfRequestModelTests.cs | 65 ---- .../Utilities/KdfSettingsValidatorTests.cs | 36 ++ .../Kdf/ChangeKdfCommandTests.cs | 322 ++++++++++++++++++ 25 files changed, 729 insertions(+), 173 deletions(-) delete mode 100644 src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs rename src/Api/Auth/Models/Request/Accounts/{MasterPasswordUnlockDataModel.cs => MasterPasswordUnlockDataAndAuthenticationModel.cs} (90%) create mode 100644 src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs create mode 100644 src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs create mode 100644 src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs create mode 100644 src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs create mode 100644 src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs create mode 100644 src/Core/KeyManagement/Models/Data/KdfSettings.cs create mode 100644 src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs create mode 100644 src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs delete mode 100644 test/Api.Test/Models/Request/Accounts/KdfRequestModelTests.cs create mode 100644 test/Api.Test/Utilities/KdfSettingsValidatorTests.cs create mode 100644 test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 0bed7c29c4..501399db38 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -16,6 +16,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; @@ -39,7 +40,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 +52,8 @@ public class AccountsController : Controller ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IFeatureService featureService, - ITwoFactorEmailService twoFactorEmailService + ITwoFactorEmailService twoFactorEmailService, + IChangeKdfCommand changeKdfCommand ) { _organizationService = organizationService; @@ -64,7 +66,7 @@ public class AccountsController : Controller _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _featureService = featureService; _twoFactorEmailService = twoFactorEmailService; - + _changeKdfCommand = changeKdfCommand; } @@ -256,7 +258,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 +266,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; diff --git a/src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs deleted file mode 100644 index fc62f22bab..0000000000 --- a/src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs +++ /dev/null @@ -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 Validate(ValidationContext validationContext) - { - if (Kdf.HasValue && KdfIterations.HasValue) - { - return KdfSettingsValidator.Validate(Kdf.Value, KdfIterations.Value, KdfMemory, KdfParallelism); - } - - return Enumerable.Empty(); - } -} diff --git a/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs b/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataAndAuthenticationModel.cs similarity index 90% rename from src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs rename to src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataAndAuthenticationModel.cs index ba57788cec..da361e5a0c 100644 --- a/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataAndAuthenticationModel.cs @@ -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, diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs index 01da1f0f9f..8fa51e9f34 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs @@ -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; } } diff --git a/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs b/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs new file mode 100644 index 0000000000..904304a633 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs @@ -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 + }; + } +} diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs new file mode 100644 index 0000000000..d65dc8fcb7 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs @@ -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 + }; + } +} diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs new file mode 100644 index 0000000000..ce7a2b343f --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs @@ -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 + }; + } +} diff --git a/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs index 23c3eb95d0..3af944110c 100644 --- a/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs @@ -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 EmergencyAccessUnlockData { get; set; } public required IEnumerable OrganizationAccountRecoveryUnlockData { get; set; } public required IEnumerable PasskeyUnlockData { get; set; } diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index b92d22b0e3..12c527ed78 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -78,6 +78,11 @@ public class User : ITableObject, 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(); diff --git a/src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs new file mode 100644 index 0000000000..081ae5248d --- /dev/null +++ b/src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs @@ -0,0 +1,15 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.KeyManagement.Kdf; + +/// +/// 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. +/// +public interface IChangeKdfCommand +{ + public Task ChangeKdfAsync(User user, string masterPasswordAuthenticationHash, MasterPasswordAuthenticationData authenticationData, MasterPasswordUnlockData unlockData); +} diff --git a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs new file mode 100644 index 0000000000..fe736f9ac6 --- /dev/null +++ b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs @@ -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; + +/// +public class ChangeKdfCommand : IChangeKdfCommand +{ + private readonly IUserService _userService; + private readonly IPushNotificationService _pushService; + private readonly IUserRepository _userRepository; + private readonly IdentityErrorDescriber _identityErrorDescriber; + private readonly ILogger _logger; + + public ChangeKdfCommand(IUserService userService, IPushNotificationService pushService, IUserRepository userRepository, IdentityErrorDescriber describer, ILogger logger) + { + _userService = userService; + _pushService = pushService; + _userRepository = userRepository; + _identityErrorDescriber = describer; + _logger = logger; + } + + public async Task 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; + } +} diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index cacf3d4140..e4ebdb4860 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -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(); + services.AddScoped(); } } diff --git a/src/Core/KeyManagement/Models/Data/KdfSettings.cs b/src/Core/KeyManagement/Models/Data/KdfSettings.cs new file mode 100644 index 0000000000..cc1e465330 --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/KdfSettings.cs @@ -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); + } +} diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs new file mode 100644 index 0000000000..c0ae949a3f --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs @@ -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."); + } + } +} diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs new file mode 100644 index 0000000000..e305d92fec --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs @@ -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; + } + } +} diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs index 0ddfc03190..d1ab6f645b 100644 --- a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs @@ -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."); } } } diff --git a/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs index b89f19797f..557fb56ff3 100644 --- a/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs +++ b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs @@ -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 EmergencyAccesses { get; set; } public IReadOnlyList OrganizationUsers { get; set; } public IEnumerable WebAuthnKeys { get; set; } diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index ef602be93a..412f9db36e 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -38,8 +38,6 @@ public interface IUserService Task ConvertToKeyConnectorAsync(User user); Task AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key); Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint); - Task ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, string key, - KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true); Task DisableTwoFactorProviderAsync(User user, TwoFactorProviderType type); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 386cb8c3d2..a36b9e37cc 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -777,39 +777,6 @@ public class UserService : UserManager, IUserService return IdentityResult.Success; } - public async Task 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 RefreshSecurityStampAsync(User user, string secret) { if (user == null) diff --git a/src/Core/Utilities/KdfSettingsValidator.cs b/src/Core/Utilities/KdfSettingsValidator.cs index db7936acff..f89e8ddb66 100644 --- a/src/Core/Utilities/KdfSettingsValidator.cs +++ b/src/Core/Utilities/KdfSettingsValidator.cs @@ -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 Validate(KdfSettings settings) + { + return Validate(settings.KdfType, settings.Iterations, settings.Memory, settings.Parallelism); + } } diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index ce870c0860..e81d51281d 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -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(); _featureService = Substitute.For(); _twoFactorEmailService = Substitute.For(); - + _changeKdfCommand = Substitute.For(); _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(), Arg.Any(), Arg.Any(), Arg.Any()) .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(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -256,7 +265,13 @@ public class AccountsControllerTests : IDisposable ConfigureUserServiceToReturnNullPrincipal(); await Assert.ThrowsAsync( - () => _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(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Failed())); await Assert.ThrowsAsync( - () => _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()).Returns(Task.FromResult(user)); + model.AuthenticationData = null; + + // Act + await Assert.ThrowsAsync(() => _sut.PostKdf(model)); + } + + [Theory] + [BitAutoData] + public async Task PostKdf_WithNullUnlockData_ShouldFail( + User user, PasswordRequestModel model) + { + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + model.UnlockData = null; + + // Act + await Assert.ThrowsAsync(() => _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 diff --git a/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs b/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs index 4c78c7015a..254f57a128 100644 --- a/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs +++ b/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs @@ -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 Validate(MasterPasswordUnlockDataModel model) + private static List Validate(MasterPasswordUnlockAndAuthenticationDataModel model) { var results = new List(); Validator.TryValidateObject(model, new ValidationContext(model), results, true); diff --git a/test/Api.Test/Models/Request/Accounts/KdfRequestModelTests.cs b/test/Api.Test/Models/Request/Accounts/KdfRequestModelTests.cs deleted file mode 100644 index 612b7ad442..0000000000 --- a/test/Api.Test/Models/Request/Accounts/KdfRequestModelTests.cs +++ /dev/null @@ -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 Validate(KdfRequestModel model) - { - var results = new List(); - Validator.TryValidateObject(model, new ValidationContext(model), results); - return results; - } -} diff --git a/test/Api.Test/Utilities/KdfSettingsValidatorTests.cs b/test/Api.Test/Utilities/KdfSettingsValidatorTests.cs new file mode 100644 index 0000000000..5c556979c7 --- /dev/null +++ b/test/Api.Test/Utilities/KdfSettingsValidatorTests.cs @@ -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()); + } +} diff --git a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs new file mode 100644 index 0000000000..02e04b9ce9 --- /dev/null +++ b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs @@ -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 sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + sutProvider.GetDependency().UpdatePasswordHash(Arg.Any(), Arg.Any()).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().Received(1).ReplaceAsync(Arg.Is(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 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(async () => + await sutProvider.Sut.ChangeKdfAsync(null!, "masterPassword", authenticationData, unlockData)); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_WrongPassword_ReturnsPasswordMismatch(SutProvider sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).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 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().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + sutProvider.GetDependency().UpdatePasswordHash(Arg.Any(), Arg.Any()).Returns(Task.FromResult(IdentityResult.Success)); + + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(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 sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).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(async () => + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider sutProvider, User user, KdfSettings kdf) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).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(async () => + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider sutProvider, User user, KdfSettings kdf) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).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(async () => + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvider sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + var failedResult = IdentityResult.Failed(new IdentityError { Code = "TestFail", Description = "Test fail" }); + sutProvider.GetDependency().UpdatePasswordHash(Arg.Any(), Arg.Any()).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 sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).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(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 sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).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(async () => + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + + Assert.Equal("KDF settings are invalid.", exception.Message); + } + +} From 6e4f05ebd388e9a1dc9ec8103d070b516b65c1ae Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Wed, 24 Sep 2025 08:42:56 -0400 Subject: [PATCH 22/31] fix: change policies to static strings and update auth owned endpoints (#6296) --- src/Api/Auth/Controllers/AccountsController.cs | 3 ++- .../Auth/Controllers/AuthRequestsController.cs | 3 ++- .../Controllers/EmergencyAccessController.cs | 2 +- src/Api/Auth/Controllers/TwoFactorController.cs | 3 ++- src/Api/Auth/Controllers/WebAuthnController.cs | 3 ++- src/Api/Startup.cs | 17 +++++++++-------- src/Core/Auth/Identity/Policies.cs | 8 +++++++- 7 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 501399db38..19165a5a1c 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -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; @@ -27,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; diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index e4a9027f20..4da3a2f491 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -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, diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index b849dc3e07..016cd82fe2 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -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; diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 886ed2cd20..0af46fb57c 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -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; diff --git a/src/Api/Auth/Controllers/WebAuthnController.cs b/src/Api/Auth/Controllers/WebAuthnController.cs index bb17607954..60b8621c5e 100644 --- a/src/Api/Auth/Controllers/WebAuthnController.cs +++ b/src/Api/Auth/Controllers/WebAuthnController.cs @@ -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; diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 1d5a1609f4..cc50a1b362 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -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 => diff --git a/src/Core/Auth/Identity/Policies.cs b/src/Core/Auth/Identity/Policies.cs index 78d86d06a4..b2d94b0a6e 100644 --- a/src/Core/Auth/Identity/Policies.cs +++ b/src/Core/Auth/Identity/Policies.cs @@ -6,5 +6,11 @@ public static class Policies /// Policy for managing access to the Send feature. /// 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)] } From 6edab46d979f3bc28c733a8f9cb2bfbee0251cee Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:52:04 -0500 Subject: [PATCH 23/31] [PM-24357] Do not purge ciphers in the default collection (#6320) * do not purge ciphers in the default collection * Update `DeleteByOrganizationId` procedure to be more performant based on PR review feedback * update EF integration for purge to match new SQL implementation * update Cipher_DeleteByOrganizationId based on PR feedback from dbops team --- .../Vault/Repositories/CipherRepository.cs | 23 +-- .../Cipher/Cipher_DeleteByOrganizationId.sql | 120 ++++++++----- .../Repositories/CipherRepositoryTests.cs | 160 ++++++++++++++++++ ...9-22_00_ExcludeDefaultCiphersFromPurge.sql | 91 ++++++++++ 4 files changed, 345 insertions(+), 49 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-09-22_00_ExcludeDefaultCiphersFromPurge.sql diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index 4b2d09f87b..d88f0e98bb 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -281,17 +281,20 @@ public class CipherRepository : Repository + 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); diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql index d2324a1d00..c14a612b0f 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql @@ -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 \ No newline at end of file + --------------------------------------------------------------------- + -- 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 diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index ef28d776d7..ee8cd0247d 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -1241,4 +1241,164 @@ public class CipherRepositoryTests Assert.NotNull(archivedCipher); Assert.NotNull(archivedCipher.ArchivedDate); } + + [DatabaseTheory, DatabaseData] + public async Task DeleteByOrganizationIdAsync_ExcludesDefaultCollectionCiphers( + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + ICipherRepository cipherRepository, + ICollectionRepository collectionRepository, + ICollectionCipherRepository collectionCipherRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = user.Email, + Plan = "Test" + }); + + var defaultCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Default Collection", + OrganizationId = organization.Id, + Type = CollectionType.DefaultUserCollection + }); + + var sharedCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Shared Collection", + OrganizationId = organization.Id, + }); + + async Task CreateOrgCipherAsync() => await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var cipherInDefaultCollection = await CreateOrgCipherAsync(); + var cipherInSharedCollection = await CreateOrgCipherAsync(); + var cipherInBothCollections = await CreateOrgCipherAsync(); + var unassignedCipher = await CreateOrgCipherAsync(); + + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInDefaultCollection.Id, organization.Id, + new List { defaultCollection.Id }); + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInSharedCollection.Id, organization.Id, + new List { sharedCollection.Id }); + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInBothCollections.Id, organization.Id, + new List { defaultCollection.Id, sharedCollection.Id }); + + await cipherRepository.DeleteByOrganizationIdAsync(organization.Id); + + var remainingCipherInDefault = await cipherRepository.GetByIdAsync(cipherInDefaultCollection.Id); + var deletedCipherInShared = await cipherRepository.GetByIdAsync(cipherInSharedCollection.Id); + var remainingCipherInBoth = await cipherRepository.GetByIdAsync(cipherInBothCollections.Id); + var deletedUnassignedCipher = await cipherRepository.GetByIdAsync(unassignedCipher.Id); + + Assert.Null(deletedCipherInShared); + Assert.Null(deletedUnassignedCipher); + + Assert.NotNull(remainingCipherInDefault); + Assert.NotNull(remainingCipherInBoth); + + var remainingCollectionCiphers = await collectionCipherRepository.GetManyByOrganizationIdAsync(organization.Id); + + // Should still have the default collection cipher relationships + Assert.Contains(remainingCollectionCiphers, cc => + cc.CipherId == cipherInDefaultCollection.Id && cc.CollectionId == defaultCollection.Id); + Assert.Contains(remainingCollectionCiphers, cc => + cc.CipherId == cipherInBothCollections.Id && cc.CollectionId == defaultCollection.Id); + + // Should not have the shared collection cipher relationships + Assert.DoesNotContain(remainingCollectionCiphers, cc => cc.CollectionId == sharedCollection.Id); + } + + [DatabaseTheory, DatabaseData] + public async Task DeleteByOrganizationIdAsync_DeletesAllWhenNoDefaultCollections( + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + ICipherRepository cipherRepository, + ICollectionRepository collectionRepository, + ICollectionCipherRepository collectionCipherRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = user.Email, + Plan = "Test" + }); + + var sharedCollection1 = await collectionRepository.CreateAsync(new Collection + { + Name = "Shared Collection 1", + OrganizationId = organization.Id, + Type = CollectionType.SharedCollection + }); + + var sharedCollection2 = await collectionRepository.CreateAsync(new Collection + { + Name = "Shared Collection 2", + OrganizationId = organization.Id, + Type = CollectionType.SharedCollection + }); + + // Create ciphers + var cipherInSharedCollection1 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var cipherInSharedCollection2 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var unassignedCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInSharedCollection1.Id, organization.Id, + new List { sharedCollection1.Id }); + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInSharedCollection2.Id, organization.Id, + new List { sharedCollection2.Id }); + + await cipherRepository.DeleteByOrganizationIdAsync(organization.Id); + + var deletedCipher1 = await cipherRepository.GetByIdAsync(cipherInSharedCollection1.Id); + var deletedCipher2 = await cipherRepository.GetByIdAsync(cipherInSharedCollection2.Id); + var deletedUnassignedCipher = await cipherRepository.GetByIdAsync(unassignedCipher.Id); + + Assert.Null(deletedCipher1); + Assert.Null(deletedCipher2); + Assert.Null(deletedUnassignedCipher); + + // All collection cipher relationships should be removed + var remainingCollectionCiphers = await collectionCipherRepository.GetManyByOrganizationIdAsync(organization.Id); + Assert.Empty(remainingCollectionCiphers); + } } diff --git a/util/Migrator/DbScripts/2025-09-22_00_ExcludeDefaultCiphersFromPurge.sql b/util/Migrator/DbScripts/2025-09-22_00_ExcludeDefaultCiphersFromPurge.sql new file mode 100644 index 0000000000..8eb37a570f --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-22_00_ExcludeDefaultCiphersFromPurge.sql @@ -0,0 +1,91 @@ +CREATE OR ALTER PROCEDURE [dbo].[Cipher_DeleteByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER + AS + BEGIN + SET NOCOUNT ON; + + DECLARE @BatchSize INT = 1000; + + BEGIN TRY + BEGIN TRANSACTION; + + --------------------------------------------------------------------- + -- 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; + + IF @@ROWCOUNT = 0 BREAK; + 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; + + IF @@ROWCOUNT = 0 BREAK; + END + + --------------------------------------------------------------------- + -- 3. Bump revision date (inside transaction for consistency) + --------------------------------------------------------------------- + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId; + + COMMIT TRANSACTION ; + + --------------------------------------------------------------------- + -- 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 From 68f7e8c15c4284ee26ef5121ee935d2f525e3b3c Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:30:43 -0400 Subject: [PATCH 24/31] chore(feature-flag) Added feature flag for pm-22110-disable-alternate-login-methods --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 96ee509db1..5c00db9da4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -144,6 +144,7 @@ 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"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; From 4b10c1641989c6e65b299d3343d46b499cdf44fd Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Wed, 24 Sep 2025 18:23:15 -0400 Subject: [PATCH 25/31] fix(global-settings): [PM-26092] Token Refresh Doc Enhancement (#6367) * fix(global-settings): [PM-26092] Token Refresh Doc Enhancement - Enhanced documentation and wording for token refresh. --- src/Core/Settings/GlobalSettings.cs | 25 ++++++++++++++++++++---- src/Identity/IdentityServer/ApiClient.cs | 2 +- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 107fd29236..546e668093 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -473,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"; /// - /// 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). /// public int? SlidingRefreshTokenLifetimeSeconds { get; set; } /// - /// 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. /// public int? AbsoluteRefreshTokenLifetimeSeconds { get; set; } /// - /// 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). /// - public bool UseAbsoluteRefreshTokenExpiration { get; set; } = false; + public bool ApplyAbsoluteExpirationOnRefreshToken { get; set; } = false; } public class DataProtectionSettings diff --git a/src/Identity/IdentityServer/ApiClient.cs b/src/Identity/IdentityServer/ApiClient.cs index 61b51797c0..ead19813ec 100644 --- a/src/Identity/IdentityServer/ApiClient.cs +++ b/src/Identity/IdentityServer/ApiClient.cs @@ -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; From f0953ed6b0007af0168993a57f2c5f601c90d16c Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 24 Sep 2025 15:25:40 -0700 Subject: [PATCH 26/31] [PM-26126] Add includeMemberItems query param to GET /organization-details (#6376) --- src/Api/Vault/Controllers/CiphersController.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 6249e264c0..c0a974bce2 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -336,13 +336,15 @@ public class CiphersController : Controller } [HttpGet("organization-details")] - public async Task> GetOrganizationCiphers(Guid organizationId) + public async Task> 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) : From b83f95f78ccb475006e2118909a6d8cf30fb605c Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:14:02 +1000 Subject: [PATCH 27/31] [PM-25097] Remove DeleteClaimedUserAccountRefactor flag (#6364) * Remove feature flag * Remove old code --- .../OrganizationUsersController.cs | 81 +-- .../CommandResult.cs | 2 +- ...eClaimedOrganizationUserAccountCommand.cs} | 12 +- ...ClaimedOrganizationUserAccountValidator.cs | 8 +- .../DeleteUserValidationRequest.cs | 2 +- .../Errors.cs | 2 +- ...eClaimedOrganizationUserAccountCommand.cs} | 4 +- ...laimedOrganizationUserAccountValidator.cs} | 4 +- .../ValidationResult.cs | 2 +- ...teClaimedOrganizationUserAccountCommand.cs | 239 -------- ...teClaimedOrganizationUserAccountCommand.cs | 19 - src/Core/Constants.cs | 1 - ...OrganizationServiceCollectionExtensions.cs | 8 +- .../OrganizationUserControllerTests.cs | 6 +- .../OrganizationUsersControllerTests.cs | 26 +- ...medOrganizationUserAccountCommandTests.cs} | 40 +- ...dOrganizationUserAccountValidatorTests.cs} | 34 +- ...imedOrganizationUserAccountCommandTests.cs | 526 ------------------ 18 files changed, 86 insertions(+), 930 deletions(-) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/CommandResult.cs (98%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs => DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs} (92%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/DeleteClaimedOrganizationUserAccountValidator.cs (93%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/DeleteUserValidationRequest.cs (92%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/Errors.cs (97%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs => DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs} (87%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs => DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs} (65%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/ValidationResult.cs (97%) delete mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs delete mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs rename test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/{DeleteClaimedOrganizationUserAccountCommandvNextTests.cs => DeleteClaimedOrganizationUserAccountCommandTests.cs} (93%) rename test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/{DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs => DeleteClaimedOrganizationUserAccountValidatorTests.cs} (97%) delete mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 16d6984334..74ac9b1255 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -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] - public async Task DeleteAccount(Guid orgId, Guid id) + public async Task 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( + 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 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( - error => error is NotFoundError - ? TypedResults.NotFound(new ErrorResponseModel(error.Message)) - : TypedResults.BadRequest(new ErrorResponseModel(error.Message)), - TypedResults.Ok - ); - } - [HttpDelete("delete-account")] [Authorize] public async Task> 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(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(responses); } [HttpPost("delete-account")] @@ -611,24 +590,6 @@ public class OrganizationUsersController : Controller return await BulkDeleteAccount(orgId, model); } - private async Task> 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(responses); - } - [HttpPut("{id}/revoke")] [Authorize] public async Task RevokeAsync(Guid orgId, Guid id) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs similarity index 98% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs index 3dfbe4dbda..fbb00a908a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs @@ -1,7 +1,7 @@ using OneOf; using OneOf.Types; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; /// /// Represents the result of a command. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs similarity index 92% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs index 3064a426fa..87c24c3ab4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs @@ -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 logger, - IDeleteClaimedOrganizationUserAccountValidatorvNext deleteClaimedOrganizationUserAccountValidatorvNext) - : IDeleteClaimedOrganizationUserAccountCommandvNext + ILogger logger, + IDeleteClaimedOrganizationUserAccountValidator deleteClaimedOrganizationUserAccountValidator) + : IDeleteClaimedOrganizationUserAccountCommand { public async Task 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); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs similarity index 93% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs index 7a88841d2f..315d45ea69 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs @@ -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>> ValidateAsync(IEnumerable requests) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteUserValidationRequest.cs similarity index 92% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteUserValidationRequest.cs index 5fd95dc73c..067d7ce04c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteUserValidationRequest.cs @@ -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 { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs similarity index 97% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs index d991a882b8..6c8f7ee00c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; /// /// A strongly typed error containing a reason that an action failed. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs similarity index 87% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs index 2c462a2acf..983a3a4f21 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs @@ -1,6 +1,6 @@ -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; -public interface IDeleteClaimedOrganizationUserAccountCommandvNext +public interface IDeleteClaimedOrganizationUserAccountCommand { /// /// Removes a user from an organization and deletes all of their associated user data. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs similarity index 65% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs index f6125a0355..f1a2c71b1b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs @@ -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>> ValidateAsync(IEnumerable requests); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs similarity index 97% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs index 23d2fbb7ce..c84a0aeda1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs @@ -1,7 +1,7 @@ using OneOf; using OneOf.Types; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; /// /// Represents the result of validating a request. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs deleted file mode 100644 index 60a1c8bfbf..0000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs +++ /dev/null @@ -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> DeleteManyUsersAsync(Guid organizationId, IEnumerable 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 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 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 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) { } - } - } -} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs deleted file mode 100644 index 1c79687be9..0000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs +++ /dev/null @@ -1,19 +0,0 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; - -public interface IDeleteClaimedOrganizationUserAccountCommand -{ - /// - /// Removes a user from an organization and deletes all of their associated user data. - /// - Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId); - - /// - /// Removes multiple users from an organization and deletes all of their associated user data. - /// - /// - /// An error message for each user that could not be removed, otherwise null. - /// - Task> DeleteManyUsersAsync(Guid organizationId, IEnumerable orgUserIds, Guid? deletingUserId); -} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 5c00db9da4..97c1399719 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -134,7 +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"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 1c38a27d1e..da05bc929c 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -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(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); - // vNext implementations (feature flagged) - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services) diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs index b7839467e8..7c61a88bd8 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs @@ -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 sutProvider) - { - sutProvider.GetDependency().ManageUsers(orgId).Returns(true); - sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser); - - await sutProvider.Sut.DeleteAccount(orgId, id); - - await sutProvider.GetDependency() - .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 sutProvider) { - sutProvider.GetDependency().ManageUsers(orgId).Returns(true); - sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs((User)null); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs((Guid?)null); - await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteAccount(orgId, id)); + var result = await sutProvider.Sut.DeleteAccount(orgId, id); + + Assert.IsType(result); } [Theory] diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs similarity index 93% rename from test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs rename to test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs index 679c1914c6..c223520a04 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs @@ -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 sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -65,7 +65,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WithEmptyUserIds_ReturnsEmptyResults( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid deletingUserId) { @@ -77,7 +77,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WithValidUsers_DeletesUsersAndLogsEvents( - SutProvider sutProvider, + SutProvider sutProvider, User user1, User user2, Guid organizationId, @@ -135,7 +135,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WithValidationErrors_ReturnsErrorResults( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid orgUserId1, Guid orgUserId2, @@ -183,7 +183,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WithMixedValidationResults_HandlesPartialSuccessCorrectly( - SutProvider sutProvider, + SutProvider sutProvider, User validUser, Guid organizationId, Guid validOrgUserId, @@ -243,7 +243,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_CancelPremiumsAsync_HandlesGatewayExceptionAndLogsWarning( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -285,7 +285,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests await sutProvider.GetDependency().Received(1).CancelPremiumAsync(user); await AssertSuccessfulUserOperations(sutProvider, [user], [orgUser]); - sutProvider.GetDependency>() + sutProvider.GetDependency>() .Received(1) .Log( LogLevel.Warning, @@ -299,7 +299,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task CreateInternalRequests_CreatesCorrectRequestsForAllUsers( - SutProvider sutProvider, + SutProvider sutProvider, User user1, User user2, Guid organizationId, @@ -326,7 +326,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) .Returns(claimedStatuses); - sutProvider.GetDependency() + sutProvider.GetDependency() .ValidateAsync(Arg.Any>()) .Returns(callInfo => { @@ -338,7 +338,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests await sutProvider.Sut.DeleteManyUsersAsync(organizationId, orgUserIds, deletingUserId); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) .ValidateAsync(Arg.Is>(requests => requests.Count() == 2 && @@ -359,7 +359,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task GetUsersAsync_WithNullUserIds_ReturnsEmptyCollection( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid deletingUserId, [OrganizationUser] OrganizationUser orgUserWithoutUserId) @@ -374,7 +374,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests .GetManyAsync(Arg.Is>(ids => !ids.Any())) .Returns([]); - sutProvider.GetDependency() + sutProvider.GetDependency() .ValidateAsync(Arg.Any>()) .Returns(callInfo => { @@ -386,7 +386,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUserWithoutUserId.Id], deletingUserId); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) .ValidateAsync(Arg.Is>(requests => requests.Count() == 1 && @@ -406,7 +406,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests ValidationResultHelpers.Invalid(request, error); private static void SetupRepositoryMocks( - SutProvider sutProvider, + SutProvider sutProvider, ICollection orgUsers, IEnumerable users, Guid organizationId, @@ -426,16 +426,16 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests } private static void SetupValidatorMock( - SutProvider sutProvider, + SutProvider sutProvider, IEnumerable> validationResults) { - sutProvider.GetDependency() + sutProvider.GetDependency() .ValidateAsync(Arg.Any>()) .Returns(validationResults); } private static async Task AssertSuccessfulUserOperations( - SutProvider sutProvider, + SutProvider sutProvider, IEnumerable expectedUsers, IEnumerable 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 sutProvider) + private static async Task AssertNoUserOperations(SutProvider sutProvider) { await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteManyAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushLogOutAsync(default); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorTests.cs similarity index 97% rename from test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs rename to test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorTests.cs index e51df6a626..30cc574ead 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorTests.cs @@ -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 sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -50,7 +50,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithMultipleValidRequests_ReturnsAllValidResults( - SutProvider sutProvider, + SutProvider sutProvider, User user1, User user2, Guid organizationId, @@ -97,7 +97,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithNullUser_ReturnsUserNotFoundError( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid deletingUserId, [OrganizationUser] OrganizationUser organizationUser) @@ -123,7 +123,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithNullOrganizationUser_ReturnsUserNotFoundError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId) @@ -149,7 +149,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithInvitedUser_ReturnsInvalidUserStatusError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -178,7 +178,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WhenDeletingYourself_ReturnsCannotDeleteYourselfError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, [OrganizationUser] OrganizationUser organizationUser) @@ -206,7 +206,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithUnclaimedUser_ReturnsUserNotClaimedError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -235,7 +235,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_DeletingOwnerWhenCurrentUserIsNotOwner_ReturnsCannotDeleteOwnersError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -266,7 +266,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_DeletingOwnerWhenCurrentUserIsOwner_ReturnsValidResult( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -296,7 +296,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithSoleOwnerOfOrganization_ReturnsSoleOwnerError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -331,7 +331,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithSoleProviderOwner_ReturnsSoleProviderError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -366,7 +366,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_CustomUserDeletingAdmin_ReturnsCannotDeleteAdminsError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -397,7 +397,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_AdminDeletingAdmin_ReturnsValidResult( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -427,7 +427,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithMixedValidAndInvalidRequests_ReturnsCorrespondingResults( - SutProvider sutProvider, + SutProvider sutProvider, User validUser, User invalidUser, Guid organizationId, @@ -475,7 +475,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests } private static void SetupMocks( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid userId, OrganizationUserType currentUserType = OrganizationUserType.Owner) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs deleted file mode 100644 index 7f1b101d7a..0000000000 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs +++ /dev/null @@ -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 sutProvider, User user, Guid deletingUserId, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync( - organizationUser.OrganizationId, - Arg.Is>(ids => ids.Contains(organizationUser.Id))) - .Returns(new Dictionary { { organizationUser.Id, true } }); - - sutProvider.GetDependency() - .HasConfirmedOwnersExceptAsync( - organizationUser.OrganizationId, - Arg.Is>(ids => ids.Contains(organizationUser.Id)), - includeProvider: Arg.Any()) - .Returns(true); - - // Act - await sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId); - - // Assert - await sutProvider.GetDependency().Received(1).DeleteAsync(user); - await sutProvider.GetDependency().Received(1) - .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Deleted); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_WithUserNotFound_ThrowsException( - SutProvider sutProvider, - Guid organizationId, Guid organizationUserId) - { - // Arrange - sutProvider.GetDependency() - .GetByIdAsync(organizationUserId) - .Returns((OrganizationUser?)null); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationId, organizationUserId, null)); - - // Assert - Assert.Equal("Member not found.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_DeletingYourself_ThrowsException( - SutProvider sutProvider, - User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser, - Guid deletingUserId) - { - // Arrange - organizationUser.UserId = user.Id = deletingUserId; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); - - // Assert - Assert.Equal("You cannot delete yourself.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_WhenUserIsInvited_ThrowsException( - SutProvider sutProvider, - [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser organizationUser) - { - // Arrange - organizationUser.UserId = null; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, null)); - - // Assert - Assert.Equal("You cannot delete a member with Invited status.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_WhenCustomUserDeletesAdmin_ThrowsException( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Admin)] OrganizationUser organizationUser, - Guid deletingUserId) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .OrganizationCustom(organizationUser.OrganizationId) - .Returns(true); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); - - // Assert - Assert.Equal("Custom users can not delete admins.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_DeletingOwnerWhenNotOwner_ThrowsException( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, - Guid deletingUserId) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .OrganizationOwner(organizationUser.OrganizationId) - .Returns(false); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); - - // Assert - Assert.Equal("Only owners can delete other owners.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_DeletingLastConfirmedOwner_ThrowsException( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, - Guid deletingUserId) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .OrganizationOwner(organizationUser.OrganizationId) - .Returns(true); - - sutProvider.GetDependency() - .HasConfirmedOwnersExceptAsync( - organizationUser.OrganizationId, - Arg.Is>(ids => ids.Contains(organizationUser.Id)), - includeProvider: Arg.Any()) - .Returns(false); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); - - // Assert - Assert.Equal("Organization must have at least one confirmed owner.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_WithUserNotManaged_ThrowsException( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync(organizationUser.OrganizationId, Arg.Any>()) - .Returns(new Dictionary { { organizationUser.Id, false } }); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, null)); - - // Assert - Assert.Equal("Member is not claimed by the organization.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WithValidUsers_DeletesUsersAndLogsEvents( - SutProvider 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() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser1, orgUser2 }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id))) - .Returns(new[] { user1, user2 }); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) - .Returns(new Dictionary { { 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().Received(1).GetManyAsync(userIds); - await sutProvider.GetDependency().Received(1).DeleteManyAsync(Arg.Is>(users => users.Any(u => u.Id == user1.Id) && users.Any(u => u.Id == user2.Id))); - await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync( - Arg.Is>(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 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() - .DidNotReceiveWithAnyArgs() - .DeleteManyAsync(default); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenDeletingYourself_ReturnsErrorMessage( - SutProvider sutProvider, - User user, [OrganizationUser] OrganizationUser orgUser, Guid deletingUserId) - { - // Arrange - orgUser.UserId = user.Id = deletingUserId; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(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().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenUserIsInvited_ReturnsErrorMessage( - SutProvider sutProvider, - [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser orgUser) - { - // Arrange - orgUser.UserId = null; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { 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().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenDeletingOwnerAsNonOwner_ReturnsErrorMessage( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, - Guid deletingUserId) - { - // Arrange - orgUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(i => i.Contains(user.Id))) - .Returns(new[] { user }); - - sutProvider.GetDependency() - .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().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenDeletingLastOwner_ReturnsErrorMessage( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, - Guid deletingUserId) - { - // Arrange - orgUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(i => i.Contains(user.Id))) - .Returns(new[] { user }); - - sutProvider.GetDependency() - .OrganizationOwner(orgUser.OrganizationId) - .Returns(true); - - sutProvider.GetDependency() - .HasConfirmedOwnersExceptAsync(orgUser.OrganizationId, Arg.Any>(), Arg.Any()) - .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().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenUserNotManaged_ReturnsErrorMessage( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) - { - // Arrange - orgUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser.UserId.Value))) - .Returns(new[] { user }); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync(Arg.Any(), Arg.Any>()) - .Returns(new Dictionary { { 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().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_MixedValidAndInvalidUsers_ReturnsAppropriateResults( - SutProvider 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() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser1, orgUser2, orgUser3 }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user3.Id))) - .Returns(new[] { user1, user3 }); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) - .Returns(new Dictionary { { 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().Received(1).LogOrganizationUserEventsAsync( - Arg.Is>(events => - events.Count(e => e.Item1.Id == orgUser1.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1)); - } -} From 179684a9e64b22a9aa2c0f77c5021e179824ea6e Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Thu, 25 Sep 2025 07:46:59 +0200 Subject: [PATCH 28/31] Begin pilot program for Claude code reviews with initial system prompt (#6371) * Rough draft of a markdown file to give context to Claude. --- CLAUDE.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..db0252ad8c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# Bitwarden Server - Claude Code Configuration + +## Critical Rules + +- **NEVER** edit: `/bin/`, `/obj/`, `/.git/`, `/.vs/`, `/packages/`, generated migration files +- **Security First**: All code changes must prioritize cryptographic integrity and data protection +- **Test Coverage**: New features require xUnit unit tests with NSubstitute mocking +- **Check CODEOWNERS requirements**: The repo has a `.github/CODEOWNERS` file to define team ownership for different parts of the codebase. Respect that code owners have final authority over their designated areas + +## Project Context + +**Architecture**: CQRS pattern with feature-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 + +## Development Standards + +### CQRS Pattern + +- Commands: `/src/Core/[Feature]/Commands/` +- Queries: `/src/Core/[Feature]/Queries/` +- Handlers implement `ICommandHandler` or `IQueryHandler` + +### API Conventions + +- RESTful endpoints with standard HTTP status codes +- Consistent error response: `{ "error": { "message": "..." } }` +- Pagination: `?skip=0&take=25` +- API versioning: `/api/v1/` + +### Database Migrations + +- **SQL Server**: Manual scripts in `/util/Migrator/DbScripts/` +- **Other DBs**: EF Core migrations via `pwsh ef_migrate.ps1` + +## 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 + +## Code Review Checklist + +- Security impact assessed +- xUnit tests added/updated +- Performance impact considered +- Error handling implemented +- Breaking changes documented +- CI passes: build, test, lint + +## References + +- [Architecture](https://contributing.bitwarden.com/architecture/server/) +- [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) From 222436589c503917b18f4536f529525116a78c50 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Thu, 25 Sep 2025 12:37:29 -0400 Subject: [PATCH 29/31] Enhance Claude instructions (#6378) * Enhance Claude instructions * Further simplify language --- CLAUDE.md | 75 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index db0252ad8c..d07bd3f3e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,38 +2,30 @@ ## Critical Rules -- **NEVER** edit: `/bin/`, `/obj/`, `/.git/`, `/.vs/`, `/packages/`, generated migration files -- **Security First**: All code changes must prioritize cryptographic integrity and data protection -- **Test Coverage**: New features require xUnit unit tests with NSubstitute mocking -- **Check CODEOWNERS requirements**: The repo has a `.github/CODEOWNERS` file to define team ownership for different parts of the codebase. Respect that code owners have final authority over their designated areas +- **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**: CQRS pattern with feature-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 +- **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 -## Development Standards +## Project Structure -### CQRS Pattern - -- Commands: `/src/Core/[Feature]/Commands/` -- Queries: `/src/Core/[Feature]/Queries/` -- Handlers implement `ICommandHandler` or `IQueryHandler` - -### API Conventions - -- RESTful endpoints with standard HTTP status codes -- Consistent error response: `{ "error": { "message": "..." } }` -- Pagination: `?skip=0&take=25` -- API versioning: `/api/v1/` - -### Database Migrations - -- **SQL Server**: Manual scripts in `/util/Migrator/DbScripts/` -- **Other DBs**: EF Core migrations via `pwsh ef_migrate.ps1` +- **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 @@ -42,20 +34,39 @@ - **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 +- 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 -- [Architecture](https://contributing.bitwarden.com/architecture/server/) -- [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/) +- [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) From 6466c00acde2d067115c6274c21e2dbad852f1d0 Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:37:36 -0400 Subject: [PATCH 30/31] fix(user-decryption-options) [PM-23174]: ManageAccountRecovery Permission Forces Master Password Set (#6230) * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Update tests, add OrganizationUser fixture customization for Permissions * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Update hasManageResetPasswordPermission evaluation. * PM-23174 - Add TODO for endpoint per sync discussion with Dave * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Clean up comments. * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Remove an outdated comment. * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Elaborate on comments around Organization User invite-time evaluation. * fix(user-decryption-options): Use currentContext for Provider relationships, update comments, and feature flag the change. * fix(user-decryption-options): Update test suite and provide additional comments for future flag removal. --------- Co-authored-by: Jared Snider --- src/Core/Constants.cs | 2 + .../UserDecryptionOptionsBuilder.cs | 104 ++++++++++++++---- .../AutoFixture/OrganizationUserFixtures.cs | 26 +++++ .../UserDecryptionOptionsBuilderTests.cs | 71 ++++++++++-- 4 files changed, 172 insertions(+), 31 deletions(-) create mode 100644 test/Identity.Test/AutoFixture/OrganizationUserFixtures.cs diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 97c1399719..ba8c1a84cd 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -144,6 +144,8 @@ public static class FeatureFlagKeys 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"; diff --git a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs index dc27842210..136c3f7298 100644 --- a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs +++ b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs @@ -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 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() diff --git a/test/Identity.Test/AutoFixture/OrganizationUserFixtures.cs b/test/Identity.Test/AutoFixture/OrganizationUserFixtures.cs new file mode 100644 index 0000000000..e4d40601c5 --- /dev/null +++ b/test/Identity.Test/AutoFixture/OrganizationUserFixtures.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using AutoFixture; +using AutoFixture.Xunit2; +using Bit.Core.Entities; +using Bit.Core.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Identity.Test.AutoFixture; + +internal class OrganizationUserWithDefaultPermissionsCustomization : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + // On OrganizationUser, Permissions can be JSON data (as string) or sometimes null. + // Entity APIs should prevent it from being anything else. + // An un-modified fixture for OrganizationUser will return a bare string Permissions{guid} + // in the member, throwing a JsonException on deserialization of a bare string. + .With(organizationUser => organizationUser.Permissions, CoreHelpers.ClassToJsonData(new Permissions()))); + } +} + +public class OrganizationUserWithDefaultPermissionsAttribute : CustomizeAttribute +{ + public override ICustomization GetCustomization(ParameterInfo parameter) => new OrganizationUserWithDefaultPermissionsCustomization(); +} diff --git a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs index b44dfe8d5f..37e88b0ec0 100644 --- a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs +++ b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs @@ -1,15 +1,20 @@ -using Bit.Core.Auth.Entities; +using Bit.Core; +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Identity.IdentityServer; +using Bit.Identity.Test.AutoFixture; using Bit.Identity.Utilities; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; +using User = Bit.Core.Entities.User; namespace Bit.Identity.Test.IdentityServer; @@ -20,6 +25,7 @@ public class UserDecryptionOptionsBuilderTests private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ILoginApprovingClientTypes _loginApprovingClientTypes; private readonly UserDecryptionOptionsBuilder _builder; + private readonly IFeatureService _featureService; public UserDecryptionOptionsBuilderTests() { @@ -27,7 +33,8 @@ public class UserDecryptionOptionsBuilderTests _deviceRepository = Substitute.For(); _organizationUserRepository = Substitute.For(); _loginApprovingClientTypes = Substitute.For(); - _builder = new UserDecryptionOptionsBuilder(_currentContext, _deviceRepository, _organizationUserRepository, _loginApprovingClientTypes); + _featureService = Substitute.For(); + _builder = new UserDecryptionOptionsBuilder(_currentContext, _deviceRepository, _organizationUserRepository, _loginApprovingClientTypes, _featureService); var user = new User(); _builder.ForUser(user); } @@ -220,19 +227,65 @@ public class UserDecryptionOptionsBuilderTests Assert.False(result.TrustedDeviceOption?.HasLoginApprovingDevice); } - [Theory, BitAutoData] + /// + /// This logic has been flagged as part of PM-23174. + /// When removing the server flag, please also remove this test, and remove the FeatureService + /// dependency from this suite and the following test. + /// + /// + /// + /// + /// + /// + /// + [Theory] + [BitAutoData(OrganizationUserType.Custom)] public async Task Build_WhenManageResetPasswordPermissions_ShouldReturnHasManageResetPasswordPermissionTrue( + OrganizationUserType organizationUserType, SsoConfig ssoConfig, SsoConfigurationData configurationData, - CurrentContextOrganization organization) + CurrentContextOrganization organization, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, + User user) { configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; ssoConfig.Data = configurationData.Serialize(); ssoConfig.OrganizationId = organization.Id; - _currentContext.Organizations.Returns(new List(new CurrentContextOrganization[] { organization })); + _currentContext.Organizations.Returns([organization]); _currentContext.ManageResetPassword(organization.Id).Returns(true); + organizationUser.Type = organizationUserType; + organizationUser.OrganizationId = organization.Id; + organizationUser.UserId = user.Id; + organizationUser.SetPermissions(new Permissions() { ManageResetPassword = true }); + _organizationUserRepository.GetByOrganizationAsync(ssoConfig.OrganizationId, user.Id).Returns(organizationUser); - var result = await _builder.WithSso(ssoConfig).BuildAsync(); + var result = await _builder.ForUser(user).WithSso(ssoConfig).BuildAsync(); + + Assert.True(result.TrustedDeviceOption?.HasManageResetPasswordPermission); + } + + [Theory] + [BitAutoData(OrganizationUserType.Custom)] + public async Task Build_WhenManageResetPasswordPermissions_ShouldFetchUserFromRepositoryAndReturnHasManageResetPasswordPermissionTrue( + OrganizationUserType organizationUserType, + SsoConfig ssoConfig, + SsoConfigurationData configurationData, + CurrentContextOrganization organization, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, + User user) + { + _featureService.IsEnabled(FeatureFlagKeys.PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword) + .Returns(true); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + ssoConfig.OrganizationId = organization.Id; + organizationUser.Type = organizationUserType; + organizationUser.OrganizationId = organization.Id; + organizationUser.UserId = user.Id; + organizationUser.SetPermissions(new Permissions() { ManageResetPassword = true }); + _organizationUserRepository.GetByOrganizationAsync(ssoConfig.OrganizationId, user.Id).Returns(organizationUser); + + var result = await _builder.ForUser(user).WithSso(ssoConfig).BuildAsync(); Assert.True(result.TrustedDeviceOption?.HasManageResetPasswordPermission); } @@ -241,7 +294,7 @@ public class UserDecryptionOptionsBuilderTests public async Task Build_WhenIsOwnerInvite_ShouldReturnHasManageResetPasswordPermissionTrue( SsoConfig ssoConfig, SsoConfigurationData configurationData, - OrganizationUser organizationUser, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, User user) { configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; @@ -258,7 +311,7 @@ public class UserDecryptionOptionsBuilderTests public async Task Build_WhenIsAdminInvite_ShouldReturnHasManageResetPasswordPermissionTrue( SsoConfig ssoConfig, SsoConfigurationData configurationData, - OrganizationUser organizationUser, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, User user) { configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; @@ -275,7 +328,7 @@ public class UserDecryptionOptionsBuilderTests public async Task Build_WhenUserHasEnrolledIntoPasswordReset_ShouldReturnHasAdminApprovalTrue( SsoConfig ssoConfig, SsoConfigurationData configurationData, - OrganizationUser organizationUser, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, User user) { configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; From 0df22ff5816ac22d6fe5b669fe83c874cc5721a6 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 25 Sep 2025 19:05:48 -0400 Subject: [PATCH 31/31] null coalesce collections to an empty array (#6381) --- .../Public/Models/Request/MemberCreateRequestModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs index 6813610325..b3182601b5 100644 --- a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs @@ -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 };