diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 1dbab08ca6..6415ef0815 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -10,8 +10,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.Services; @@ -59,6 +61,7 @@ public class OrganizationsController : Controller private readonly IPricingClient _pricingClient; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; private readonly IOrganizationBillingService _organizationBillingService; + private readonly IAutomaticUserConfirmationOrganizationPolicyComplianceValidator _automaticUserConfirmationOrganizationPolicyComplianceValidator; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -84,7 +87,8 @@ public class OrganizationsController : Controller IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand, IPricingClient pricingClient, IResendOrganizationInviteCommand resendOrganizationInviteCommand, - IOrganizationBillingService organizationBillingService) + IOrganizationBillingService organizationBillingService, + IAutomaticUserConfirmationOrganizationPolicyComplianceValidator automaticUserConfirmationOrganizationPolicyComplianceValidator) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -110,6 +114,7 @@ public class OrganizationsController : Controller _pricingClient = pricingClient; _resendOrganizationInviteCommand = resendOrganizationInviteCommand; _organizationBillingService = organizationBillingService; + _automaticUserConfirmationOrganizationPolicyComplianceValidator = automaticUserConfirmationOrganizationPolicyComplianceValidator; } [RequirePermission(Permission.Org_List_View)] @@ -250,7 +255,8 @@ public class OrganizationsController : Controller BillingEmail = organization.BillingEmail, Status = organization.Status, PlanType = organization.PlanType, - Seats = organization.Seats + Seats = organization.Seats, + UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation }; if (model.PlanType.HasValue) @@ -285,6 +291,13 @@ public class OrganizationsController : Controller return RedirectToAction("Edit", new { id }); } + if (await CheckOrganizationPolicyComplianceAsync(existingOrganizationData, organization) is { } error) + { + TempData["Error"] = error.Message; + + return RedirectToAction("Edit", new { id }); + } + await HandlePotentialProviderSeatScalingAsync( existingOrganizationData, model); @@ -312,6 +325,19 @@ public class OrganizationsController : Controller return RedirectToAction("Edit", new { id }); } + private async Task CheckOrganizationPolicyComplianceAsync(Organization existingOrganizationData, Organization updatedOrganization) + { + if (!existingOrganizationData.UseAutomaticUserConfirmation && updatedOrganization.UseAutomaticUserConfirmation) + { + var validationResult = await _automaticUserConfirmationOrganizationPolicyComplianceValidator.IsOrganizationCompliantAsync( + new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(existingOrganizationData.Id)); + + return validationResult.Match(error => error, _ => null); + } + + return null; + } + [HttpPost] [ValidateAntiForgeryToken] [RequirePermission(Permission.Org_Delete)] diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index ba6cf66859..113b46aacb 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -11,6 +11,7 @@ using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -39,6 +40,9 @@ public class TwoFactorController : Controller private readonly IDataProtectorTokenFactory _twoFactorAuthenticatorDataProtector; private readonly IDataProtectorTokenFactory _ssoEmailTwoFactorSessionDataProtector; private readonly ITwoFactorEmailService _twoFactorEmailService; + private readonly IStartTwoFactorWebAuthnRegistrationCommand _startTwoFactorWebAuthnRegistrationCommand; + private readonly ICompleteTwoFactorWebAuthnRegistrationCommand _completeTwoFactorWebAuthnRegistrationCommand; + private readonly IDeleteTwoFactorWebAuthnCredentialCommand _deleteTwoFactorWebAuthnCredentialCommand; public TwoFactorController( IUserService userService, @@ -50,7 +54,10 @@ public class TwoFactorController : Controller IDuoUniversalTokenService duoUniversalConfigService, IDataProtectorTokenFactory twoFactorAuthenticatorDataProtector, IDataProtectorTokenFactory ssoEmailTwoFactorSessionDataProtector, - ITwoFactorEmailService twoFactorEmailService) + ITwoFactorEmailService twoFactorEmailService, + IStartTwoFactorWebAuthnRegistrationCommand startTwoFactorWebAuthnRegistrationCommand, + ICompleteTwoFactorWebAuthnRegistrationCommand completeTwoFactorWebAuthnRegistrationCommand, + IDeleteTwoFactorWebAuthnCredentialCommand deleteTwoFactorWebAuthnCredentialCommand) { _userService = userService; _organizationRepository = organizationRepository; @@ -62,6 +69,9 @@ public class TwoFactorController : Controller _twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector; _ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector; _twoFactorEmailService = twoFactorEmailService; + _startTwoFactorWebAuthnRegistrationCommand = startTwoFactorWebAuthnRegistrationCommand; + _completeTwoFactorWebAuthnRegistrationCommand = completeTwoFactorWebAuthnRegistrationCommand; + _deleteTwoFactorWebAuthnCredentialCommand = deleteTwoFactorWebAuthnCredentialCommand; } [HttpGet("")] @@ -282,7 +292,7 @@ public class TwoFactorController : Controller public async Task GetWebAuthnChallenge([FromBody] SecretVerificationRequestModel model) { var user = await CheckAsync(model, false, true); - var reg = await _userService.StartWebAuthnRegistrationAsync(user); + var reg = await _startTwoFactorWebAuthnRegistrationCommand.StartTwoFactorWebAuthnRegistrationAsync(user); return reg; } @@ -291,7 +301,7 @@ public class TwoFactorController : Controller { var user = await CheckAsync(model, false); - var success = await _userService.CompleteWebAuthRegistrationAsync( + var success = await _completeTwoFactorWebAuthnRegistrationCommand.CompleteTwoFactorWebAuthnRegistrationAsync( user, model.Id.Value, model.Name, model.DeviceResponse); if (!success) { @@ -314,7 +324,18 @@ public class TwoFactorController : Controller [FromBody] TwoFactorWebAuthnDeleteRequestModel model) { var user = await CheckAsync(model, false); - await _userService.DeleteWebAuthnKeyAsync(user, model.Id.Value); + + if (!model.Id.HasValue) + { + throw new BadRequestException("Unable to delete WebAuthn credential."); + } + + var success = await _deleteTwoFactorWebAuthnCredentialCommand.DeleteTwoFactorWebAuthnCredentialAsync(user, model.Id.Value); + if (!success) + { + throw new BadRequestException("Unable to delete WebAuthn credential."); + } + var response = new TwoFactorWebAuthnResponseModel(user); return response; } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index 241e595333..579804df0f 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -102,7 +102,7 @@ public class AccountBillingVNextController( [BindNever] User user) { var subscription = await getBitwardenSubscriptionQuery.Run(user); - return TypedResults.Ok(subscription); + return subscription == null ? TypedResults.NotFound() : TypedResults.Ok(subscription); } [HttpPost("subscription/reinstate")] diff --git a/src/Api/Controllers/SsoCookieVendorController.cs b/src/Api/Platform/SsoCookieVendor/Controllers/SsoCookieVendorController.cs similarity index 97% rename from src/Api/Controllers/SsoCookieVendorController.cs rename to src/Api/Platform/SsoCookieVendor/Controllers/SsoCookieVendorController.cs index 4d45415a4f..9c5a637f43 100644 --- a/src/Api/Controllers/SsoCookieVendorController.cs +++ b/src/Api/Platform/SsoCookieVendor/Controllers/SsoCookieVendorController.cs @@ -3,7 +3,7 @@ using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Controllers; +namespace Bit.Api.Platform.SsoCookieVendor; /// /// Provides an endpoint to read an SSO cookie and redirect to a custom URI @@ -114,6 +114,6 @@ public class SsoCookieVendorController(IGlobalSettings globalSettings) : Control // event a user agent decides the URI is too long. queryParams.Add("d=1"); - return $"bitwarden://sso_cookie_vendor?{string.Join("&", queryParams)}"; + return $"bitwarden://sso-cookie-vendor?{string.Join("&", queryParams)}"; } } diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 5b9015b71a..7ac9c28139 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -303,7 +303,8 @@ public class Startup { swaggerDoc.Servers = [ - new() { + new() + { Url = globalSettings.BaseServiceUri.Api, } ]; diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs index af7fe8f12b..7c2402ea94 100644 --- a/src/Api/Tools/Controllers/SendsController.cs +++ b/src/Api/Tools/Controllers/SendsController.cs @@ -82,11 +82,9 @@ public class SendsController : Controller throw new BadRequestException("Could not locate send"); } - /* This guard can be removed once feature flag is retired*/ - var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP); - if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null) + if (send.AuthType == AuthType.Email && send.Emails is not null) { - return new UnauthorizedResult(); + throw new NotFoundException(); } var sendAuthResult = @@ -137,11 +135,9 @@ public class SendsController : Controller throw new BadRequestException("Could not locate send"); } - /* This guard can be removed once feature flag is retired*/ - var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP); - if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null) + if (send.AuthType == AuthType.Email && send.Emails is not null) { - return new UnauthorizedResult(); + throw new NotFoundException(); } var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, @@ -229,7 +225,6 @@ public class SendsController : Controller } [Authorize(Policy = Policies.Send)] - // [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */ [HttpPost("access/")] public async Task AccessUsingAuth() { @@ -240,6 +235,13 @@ public class SendsController : Controller throw new BadRequestException("Could not locate send"); } + /* This guard can be removed once feature flag is retired*/ + var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP); + if (!sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null) + { + throw new NotFoundException(); + } + if (!INonAnonymousSendCommand.SendCanBeAccessed(send)) { throw new NotFoundException(); @@ -270,7 +272,6 @@ public class SendsController : Controller } [Authorize(Policy = Policies.Send)] - // [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */ [HttpPost("access/file/{fileId}")] public async Task GetSendFileDownloadDataUsingAuth(string fileId) { @@ -282,6 +283,13 @@ public class SendsController : Controller throw new BadRequestException("Could not locate send"); } + /* This guard can be removed once feature flag is retired*/ + var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP); + if (!sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null) + { + throw new NotFoundException(); + } + var (url, result) = await _nonAnonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId); if (result.Equals(SendAccessResult.Denied)) diff --git a/src/Api/Tools/Models/Request/SendRequestModel.cs b/src/Api/Tools/Models/Request/SendRequestModel.cs index f3308dbd5a..cd76f26732 100644 --- a/src/Api/Tools/Models/Request/SendRequestModel.cs +++ b/src/Api/Tools/Models/Request/SendRequestModel.cs @@ -3,7 +3,6 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json; -using Bit.Api.Tools.Utilities; using Bit.Core.Exceptions; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; @@ -264,8 +263,9 @@ public class SendRequestModel } else { - // Neither Password nor Emails provided - preserve existing values and infer AuthType - existingSend.AuthType = SendUtilities.InferAuthType(existingSend); + existingSend.Emails = null; + existingSend.Password = null; + existingSend.AuthType = Core.Tools.Enums.AuthType.None; } existingSend.Disabled = Disabled.GetValueOrDefault(); diff --git a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs index 548a41879c..ca2d61e8c3 100644 --- a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs +++ b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs @@ -104,26 +104,47 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler var unpaidSubscriptions = subscriptions?.Data.Where(subscription => subscription.Status == StripeConstants.SubscriptionStatus.Unpaid).ToList(); - if (unpaidSubscriptions == null || unpaidSubscriptions.Count == 0) + var incompleteSubscriptions = subscriptions?.Data.Where(subscription => + subscription.Status == StripeConstants.SubscriptionStatus.Incomplete).ToList(); + + // Process unpaid subscriptions + if (unpaidSubscriptions != null && unpaidSubscriptions.Count > 0) + { + foreach (var subscription in unpaidSubscriptions) + { + await AttemptToPayOpenSubscriptionAsync(subscription); + } + } + + // Process incomplete subscriptions - only if there's exactly one to avoid overcharging + if (incompleteSubscriptions == null || incompleteSubscriptions.Count == 0) { return; } - foreach (var unpaidSubscription in unpaidSubscriptions) - { - await AttemptToPayOpenSubscriptionAsync(unpaidSubscription); - } - } - - private async Task AttemptToPayOpenSubscriptionAsync(Subscription unpaidSubscription) - { - var latestInvoice = unpaidSubscription.LatestInvoice; - - if (unpaidSubscription.LatestInvoice is null) + if (incompleteSubscriptions.Count > 1) { _logger.LogWarning( - "Attempted to pay unpaid subscription {SubscriptionId} but latest invoice didn't exist", - unpaidSubscription.Id); + "Customer {CustomerId} has {Count} incomplete subscriptions. Skipping automatic payment retry to avoid overcharging. Subscription IDs: {SubscriptionIds}", + customer.Id, + incompleteSubscriptions.Count, + string.Join(", ", incompleteSubscriptions.Select(s => s.Id))); + return; + } + + // Exactly one incomplete subscription - safe to retry + await AttemptToPayOpenSubscriptionAsync(incompleteSubscriptions.First()); + } + + private async Task AttemptToPayOpenSubscriptionAsync(Subscription subscription) + { + var latestInvoice = subscription.LatestInvoice; + + if (subscription.LatestInvoice is null) + { + _logger.LogWarning( + "Attempted to pay subscription {SubscriptionId} with status {Status} but latest invoice didn't exist", + subscription.Id, subscription.Status); return; } @@ -131,8 +152,8 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler if (latestInvoice.Status != StripeInvoiceStatus.Open) { _logger.LogWarning( - "Attempted to pay unpaid subscription {SubscriptionId} but latest invoice wasn't \"open\"", - unpaidSubscription.Id); + "Attempted to pay subscription {SubscriptionId} with status {Status} but latest invoice wasn't \"open\"", + subscription.Id, subscription.Status); return; } @@ -144,8 +165,8 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler catch (Exception e) { _logger.LogError(e, - "Attempted to pay open invoice {InvoiceId} on unpaid subscription {SubscriptionId} but encountered an error", - latestInvoice.Id, unpaidSubscription.Id); + "Attempted to pay open invoice {InvoiceId} on subscription {SubscriptionId} with status {Status} but encountered an error", + latestInvoice.Id, subscription.Id, subscription.Status); throw; } } diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 4507d9e308..e4710f7dce 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -69,7 +69,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler var currentPeriodEnd = subscription.GetCurrentPeriodEnd(); - if (SubscriptionWentUnpaid(parsedEvent, subscription)) + if (SubscriptionWentUnpaid(parsedEvent, subscription) || + SubscriptionWentIncompleteExpired(parsedEvent, subscription)) { await DisableSubscriberAsync(subscriberId, currentPeriodEnd); await SetSubscriptionToCancelAsync(subscription); @@ -111,6 +112,18 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle }; + private static bool SubscriptionWentIncompleteExpired( + Event parsedEvent, + Subscription currentSubscription) => + parsedEvent.Data.PreviousAttributes.ToObject() is Subscription + { + Status: SubscriptionStatus.Incomplete + } && currentSubscription is + { + Status: SubscriptionStatus.IncompleteExpired, + LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle + }; + private static bool SubscriptionBecameActive( Event parsedEvent, Subscription currentSubscription) => diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs index cf310a19af..837f1b2081 100644 --- a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs @@ -1,6 +1,6 @@ - + @@ -8,807 +8,786 @@ - - - + + - - - - - - - - - + + + + - + - - - - - -
- - - - - -
- + + + + + +
+ + + + + +
+ - + - + + +
- - - - - - - - -
- - - - - -
- - - - - - - -
- - -
- - - - - - - - - - - - - - - - - -
- - - - - - - -
- - - -
- -
- -

- You can now share passwords with members of {{OrganizationName}}! -

- -
- - - - - - - -
- - Log in - -
- -
- -
- - - -
- - - - - - - - - -
- - - - - - - -
- - - -
- -
- -
- - -
- -
- - - - - -
- - +
+ + + + + + + + +
+ + + + + +
+ + + + + - +
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+
+ +

+ You can now share passwords with members of {{OrganizationName}}! +

+ +
+ + + + + + + +
+ + Log in + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
- -
- - - - + + + + + + + +
+ + +
+ +
+ + + + - - - - -
- + + + + +
+ - + - + + +
- - - -
- - - - - - - -
- - -
- - - - - - - - - -
- -
As a member of {{OrganizationName}}:
- -
- -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - Organization Icon - -
- -
- -
- - - -
- - - - - - - - - -
- -
Your account is owned by {{OrganizationName}} and is subject to their security and management policies.
- -
- -
- - -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - Group Users Icon - -
- -
- -
- - - -
- - - - - - - - - - - - - -
- -
You can easily access and share passwords with your team.
- -
- - - -
- -
- - -
- - -
- -
- - - - - -
- - - - - - - -
- -
- -
- - - +
+ + + +
+ + + + + - +
+ + +
+ + + + + + + + + +
+ +
As a member of {{OrganizationName}}:
+
+ +
+ + +
- -
- - - - + + + + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Organization Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
Your account is owned by {{OrganizationName}} and is subject to their security and management policies.
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Share Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + +
+ +
You can easily access and share passwords with your team.
+ +
+ + + +
+ +
+ + +
+ + +
+ +
+ + + +
+ +
+ + + + - - - - -
- + + + + +
+ - + - + + +
- - - -
- - - - - - - -
- - -
- - - - - - - - - -
- -

- Learn more about Bitwarden -

- Find user guides, product documentation, and videos on the - Bitwarden Help Center.
- -
- -
- - - -
- - - - - - - - - -
- -
- - -
- -
- - - +
+ + + +
+ + + + + - +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center. +

+
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
- -
- - - - + + + + + +
+ +
+ + + + - - - - -
- + + + + +
+ - + - + +
- - -
- - - - - - - - - - - - - -
- - - - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - -
- -

- © {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa - Barbara, CA, USA -

-

- Always confirm you are on a trusted Bitwarden domain before logging - in:
- bitwarden.com | - Learn why we include this -

- -
- -
- - +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + - + +
+ + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
- -
- - - - - -
- - + +
+ + + + + +
+ + + \ No newline at end of file diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs index 40ea484aa2..e53af9eebe 100644 --- a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs @@ -1,6 +1,6 @@ - + @@ -8,976 +8,958 @@ - - - + + - - - - - - - - - + + + + - + - - - - - -
- - - - - -
- + + + + + + +
+ + + + + +
+ - + - + + +
- - - - - - - - -
- - - - - -
- - - - - - - -
- - -
- - - - - - - - - - - - - - - - - -
- - - - - - - -
- - - -
- -
- -

- You can now share passwords with members of {{OrganizationName}}! -

- -
- - - - - - - -
- - Log in - -
- -
- -
- - - -
- - - - - - - - - -
- - - - - - - -
- - - -
- -
- -
- - -
- -
- - - - - -
- - +
+ + + + + + + + +
+ + + + + +
+ + + + + - +
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+
+ +

+ You can now share passwords with members of {{OrganizationName}}! +

+ +
+ + + + + + + +
+ + Log in + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
- -
- - - - + + + + + + + +
+ + +
+ +
+ + + + - - - - -
- + + + + +
+ - + - + + +
- - - -
- - - - - - - -
- - -
- - - - - - - - - -
- -
As a member of {{OrganizationName}}:
- -
- -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - Collections Icon - -
- -
- -
- - - -
- - - - - - - - - -
- -
You can access passwords {{OrganizationName}} has shared with you.
- -
- -
- - -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - Group Users Icon - -
- -
- -
- - - -
- - - - - - - - - - - - - -
- -
You can easily share passwords with friends, family, or coworkers.
- -
- - - -
- -
- - -
- - -
- -
- - - - - -
- - - - - - - -
- -
- -
- - - +
+ + + +
+ + + + + - +
+ + +
+ + + + + + + + + +
+ +
As a member of {{OrganizationName}}:
+
+ +
+ + +
- -
- - - - + + + + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Group Users Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
You can access passwords {{OrganizationName}} has shared with you.
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Share Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + +
+ +
You can easily share passwords with friends, family, or coworkers.
+ +
+ + + +
+ +
+ + +
+ + +
+ +
+ + + +
+ +
+ + + + - - - - -
- + + + + +
+ - + - + + +
- - - -
- - - - - - - -
- - -
- - - - - - - - - - - - - -
- -
Download Bitwarden on all devices
- -
- -
Already using the browser extension? - Download the Bitwarden mobile app from the - App Store - or Google Play - to quickly save logins and autofill forms on the go.
- -
- -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - - - Download on the App Store - - - -
- -
- -
- - - -
- - - - - - - - - -
- - - - - - - -
- - - - Get it on Google Play - - - -
- -
- -
- - -
- - -
- -
- - - +
+ + + +
+ + + + + - +
+ + +
+ + + + + + + + + + + + + +
+ +
Download Bitwarden on all devices
+
+ +
Already using the browser extension? + Download the Bitwarden mobile app from the + App Store + or Google Play + to quickly save logins and autofill forms on the go.
+ +
+ +
+ + +
- -
- - - - + + + + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + + + Download on the App Store + + + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + + Get it on Google Play + + + +
+ +
+ +
+ + +
+ + +
+ +
+ + + +
+ +
+ + + + - - - - -
- + + + + +
+ - + - + + +
- - - -
- - - - - - - -
- - -
- - - - - - - - - -
- -

- Learn more about Bitwarden -

- Find user guides, product documentation, and videos on the - Bitwarden Help Center.
- -
- -
- - - -
- - - - - - - - - -
- -
- - -
- -
- - - +
+ + + +
+ + + + + - +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center. +

+
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
- -
- - - - + + + + + +
+ +
+ + + + - - - - -
- + + + + +
+ - + - + +
- - -
- - - - - - - - - - - - - -
- - - - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - -
- -

- © {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa - Barbara, CA, USA -

-

- Always confirm you are on a trusted Bitwarden domain before logging - in:
- bitwarden.com | - Learn why we include this -

- -
- -
- - +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + - + +
+ + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
- -
- - - - - -
- - + +
+ + + + + +
+ + - + \ No newline at end of file diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index 50f194b578..ac532be9bf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -2,6 +2,7 @@ #nullable disable using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -36,6 +37,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand private readonly IFeatureService _featureService; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IAutomaticUserConfirmationPolicyEnforcementValidator _automaticUserConfirmationPolicyEnforcementValidator; + private readonly IPushAutoConfirmNotificationCommand _pushAutoConfirmNotificationCommand; public AcceptOrgUserCommand( IDataProtectionProvider dataProtectionProvider, @@ -49,7 +51,8 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IFeatureService featureService, IPolicyRequirementQuery policyRequirementQuery, - IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) + IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator, + IPushAutoConfirmNotificationCommand pushAutoConfirmNotificationCommand) { // TODO: remove data protector when old token validation removed _dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose); @@ -64,6 +67,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand _featureService = featureService; _policyRequirementQuery = policyRequirementQuery; _automaticUserConfirmationPolicyEnforcementValidator = automaticUserConfirmationPolicyEnforcementValidator; + _pushAutoConfirmNotificationCommand = pushAutoConfirmNotificationCommand; } public async Task AcceptOrgUserByEmailTokenAsync(Guid organizationUserId, User user, string emailToken, @@ -233,6 +237,11 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand await _mailService.SendOrganizationAcceptedEmailAsync(organization, user.Email, adminEmails); } + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + await _pushAutoConfirmNotificationCommand.PushAsync(user.Id, orgUser.OrganizationId); + } + return orgUser; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IPushAutoConfirmNotificationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IPushAutoConfirmNotificationCommand.cs new file mode 100644 index 0000000000..be70f4544f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IPushAutoConfirmNotificationCommand.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +public interface IPushAutoConfirmNotificationCommand +{ + Task PushAsync(Guid userId, Guid organizationId); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PushAutoConfirmNotificationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PushAutoConfirmNotificationCommand.cs new file mode 100644 index 0000000000..88afc57e7b --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/PushAutoConfirmNotificationCommand.cs @@ -0,0 +1,65 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Enums; +using Bit.Core.Models; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; + +public class PushAutoConfirmNotificationCommand : IPushAutoConfirmNotificationCommand +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IPushNotificationService _pushNotificationService; + + public PushAutoConfirmNotificationCommand( + IOrganizationUserRepository organizationUserRepository, + IPushNotificationService pushNotificationService) + { + _organizationUserRepository = organizationUserRepository; + _pushNotificationService = pushNotificationService; + } + + public async Task PushAsync(Guid userId, Guid organizationId) + { + var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId); + if (organizationUser == null) + { + throw new Exception("Organization user not found"); + } + + var admins = await _organizationUserRepository.GetManyByMinimumRoleAsync( + organizationId, + OrganizationUserType.Admin); + + var customUsersWithManagePermission = (await _organizationUserRepository.GetManyDetailsByRoleAsync( + organizationId, + OrganizationUserType.Custom)) + .Where(c => c.GetPermissions()?.ManageUsers == true) + .Select(c => c.UserId); + + var userIds = admins + .Select(a => a.UserId) + .Concat(customUsersWithManagePermission) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .Distinct(); + + foreach (var adminUserId in userIds) + { + await _pushNotificationService.PushAsync( + new PushNotification + { + Target = NotificationTarget.User, + TargetId = adminUserId, + Type = PushType.AutoConfirm, + Payload = new AutoConfirmPushNotification + { + UserId = adminUserId, + OrganizationId = organizationId, + TargetUserId = organizationUser.Id + }, + ExcludeCurrentContext = false, + }); + } + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationOrganizationPolicyComplianceValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationOrganizationPolicyComplianceValidator.cs new file mode 100644 index 0000000000..6762bc9014 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationOrganizationPolicyComplianceValidator.cs @@ -0,0 +1,58 @@ +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities.v2; +using Bit.Core.AdminConsole.Utilities.v2.Validation; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +public class AutomaticUserConfirmationOrganizationPolicyComplianceValidator( + IOrganizationUserRepository organizationUserRepository, + IProviderUserRepository providerUserRepository) + : IAutomaticUserConfirmationOrganizationPolicyComplianceValidator +{ + public async Task> + IsOrganizationCompliantAsync(AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest request) + { + var organizationUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(request.OrganizationId); + + if (await ValidateUserComplianceWithSingleOrgAsync(request, organizationUsers) is { } singleOrgNonCompliant) + { + return Invalid(request, singleOrgNonCompliant); + } + + if (await ValidateNoProviderUsersAsync(organizationUsers) is { } orgHasProviderMember) + { + return Invalid(request, orgHasProviderMember); + } + + return Valid(request); + } + + private async Task ValidateUserComplianceWithSingleOrgAsync( + AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest request, + ICollection organizationUsers) + { + var userIds = organizationUsers + .Where(u => u.UserId is not null && u.Status != OrganizationUserStatusType.Invited) + .Select(u => u.UserId!.Value); + + var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(userIds)) + .Any(uo => uo.OrganizationId != request.OrganizationId + && uo.Status != OrganizationUserStatusType.Invited); + + return hasNonCompliantUser ? new UserNotCompliantWithSingleOrganization() : null; + } + + private async Task ValidateNoProviderUsersAsync(ICollection organizationUsers) + { + var userIds = organizationUsers.Where(x => x.UserId is not null) + .Select(x => x.UserId!.Value); + + return (await providerUserRepository.GetManyByManyUsersAsync(userIds)).Count != 0 + ? new ProviderExistsInOrganization() + : null; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest.cs new file mode 100644 index 0000000000..bce44c7bed --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +public record AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(Guid OrganizationId); diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/Errors.cs new file mode 100644 index 0000000000..f4e2240330 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/Errors.cs @@ -0,0 +1,7 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +public record UserNotCompliantWithSingleOrganization() : BadRequestError("All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations."); + +public record ProviderExistsInOrganization() : BadRequestError("The organization has users with the Provider user type. Please remove provider users before enabling the Automatically confirm invited users policy."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/IAutomaticUserConfirmationOrganizationPolicyComplianceValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/IAutomaticUserConfirmationOrganizationPolicyComplianceValidator.cs new file mode 100644 index 0000000000..bb33ddaa8f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/IAutomaticUserConfirmationOrganizationPolicyComplianceValidator.cs @@ -0,0 +1,28 @@ +using Bit.Core.AdminConsole.Utilities.v2.Validation; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +/// +/// Validates that an organization meets the prerequisites for enabling the Automatic User Confirmation policy. +/// +/// +/// The following conditions must be met: +/// +/// All non-invited organization users belong only to this organization (Single Organization compliance) +/// No organization users are provider members +/// +/// +public interface IAutomaticUserConfirmationOrganizationPolicyComplianceValidator +{ + /// + /// Checks whether the organization is compliant with the Automatic User Confirmation policy prerequisites. + /// + /// The request containing the organization ID to validate. + /// + /// A that is valid if the organization is compliant, + /// or contains a or + /// error if validation fails. + /// + Task> + IsOrganizationCompliantAsync(AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index 6e0c3aa8d9..a7657dc714 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -21,12 +21,14 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddPolicyValidators(); services.AddPolicyRequirements(); services.AddPolicySideEffects(); services.AddPolicyUpdateEvents(); - services.AddScoped(); } [Obsolete("Use AddPolicyUpdateEvents instead.")] diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs index 213d18c27d..6896cfaa22 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs @@ -1,11 +1,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Enums; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Repositories; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; @@ -19,19 +16,11 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; ///
  • No provider users exist
  • /// ///
    -public class AutomaticUserConfirmationPolicyEventHandler( - IOrganizationUserRepository organizationUserRepository, - IProviderUserRepository providerUserRepository) +public class AutomaticUserConfirmationPolicyEventHandler(IAutomaticUserConfirmationOrganizationPolicyComplianceValidator validator) : IPolicyValidator, IPolicyValidationEvent, IEnforceDependentPoliciesEvent { public PolicyType Type => PolicyType.AutomaticUserConfirmation; - private const string _usersNotCompliantWithSingleOrgErrorMessage = - "All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations."; - - private const string _providerUsersExistErrorMessage = - "The organization has users with the Provider user type. Please remove provider users before enabling the Automatically confirm invited users policy."; - public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) @@ -43,7 +32,11 @@ public class AutomaticUserConfirmationPolicyEventHandler( return string.Empty; } - return await ValidateEnablingPolicyAsync(policyUpdate.OrganizationId); + return (await validator.IsOrganizationCompliantAsync( + new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(policyUpdate.OrganizationId))) + .Match( + error => error.Message, + _ => string.Empty); } public async Task ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) => @@ -51,48 +44,4 @@ public class AutomaticUserConfirmationPolicyEventHandler( public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.CompletedTask; - - private async Task ValidateEnablingPolicyAsync(Guid organizationId) - { - var organizationUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); - - var singleOrgValidationError = await ValidateUserComplianceWithSingleOrgAsync(organizationId, organizationUsers); - if (!string.IsNullOrWhiteSpace(singleOrgValidationError)) - { - return singleOrgValidationError; - } - - var providerValidationError = await ValidateNoProviderUsersAsync(organizationUsers); - if (!string.IsNullOrWhiteSpace(providerValidationError)) - { - return providerValidationError; - } - - return string.Empty; - } - - private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId, - ICollection organizationUsers) - { - var userIds = organizationUsers.Where( - u => u.UserId is not null && - u.Status != OrganizationUserStatusType.Invited) - .Select(u => u.UserId!.Value); - - var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(userIds)) - .Any(uo => uo.OrganizationId != organizationId - && uo.Status != OrganizationUserStatusType.Invited); - - return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty; - } - - private async Task ValidateNoProviderUsersAsync(ICollection organizationUsers) - { - var userIds = organizationUsers.Where(x => x.UserId is not null) - .Select(x => x.UserId!.Value); - - return (await providerUserRepository.GetManyByManyUsersAsync(userIds)).Count != 0 - ? _providerUsersExistErrorMessage - : string.Empty; - } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidator.cs index 92ba11f5a6..36634ae2ba 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidator.cs @@ -5,21 +5,17 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; -using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; public class BlockClaimedDomainAccountCreationPolicyValidator : IPolicyValidator, IPolicyValidationEvent { private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; - private readonly IFeatureService _featureService; public BlockClaimedDomainAccountCreationPolicyValidator( - IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, - IFeatureService featureService) + IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery) { _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; - _featureService = featureService; } public PolicyType Type => PolicyType.BlockClaimedDomainAccountCreation; @@ -34,12 +30,6 @@ public class BlockClaimedDomainAccountCreationPolicyValidator : IPolicyValidator public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) { - // Check if feature is enabled - if (!_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)) - { - return "This feature is not enabled"; - } - // Only validate when trying to ENABLE the policy if (policyUpdate is { Enabled: true }) { diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index ba63afb54c..d10d61f413 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -81,7 +81,6 @@ public class RegisterUserCommand : IRegisterUserCommand _emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory; _providerServiceDataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); - _featureService = featureService; } public async Task RegisterUser(User user) @@ -413,12 +412,6 @@ public class RegisterUserCommand : IRegisterUserCommand private async Task ValidateEmailDomainNotBlockedAsync(string email, Guid? excludeOrganizationId = null) { - // Only check if feature flag is enabled - if (!_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)) - { - return; - } - var emailDomain = EmailValidation.GetDomain(email); var isDomainBlocked = await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync( diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs index 2e8587eee6..de7b3fca69 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs @@ -22,7 +22,6 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai private readonly GlobalSettings _globalSettings; private readonly IMailService _mailService; private readonly IDataProtectorTokenFactory _tokenDataFactory; - private readonly IFeatureService _featureService; private readonly IOrganizationDomainRepository _organizationDomainRepository; public SendVerificationEmailForRegistrationCommand( @@ -31,7 +30,6 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai GlobalSettings globalSettings, IMailService mailService, IDataProtectorTokenFactory tokenDataFactory, - IFeatureService featureService, IOrganizationDomainRepository organizationDomainRepository) { _logger = logger; @@ -39,7 +37,6 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai _globalSettings = globalSettings; _mailService = mailService; _tokenDataFactory = tokenDataFactory; - _featureService = featureService; _organizationDomainRepository = organizationDomainRepository; } @@ -57,17 +54,14 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai } // Check if the email domain is blocked by an organization policy - if (_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)) - { - var emailDomain = EmailValidation.GetDomain(email); + var emailDomain = EmailValidation.GetDomain(email); - if (await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(emailDomain)) - { - _logger.LogInformation( - "User registration email verification blocked by domain claim policy. Domain: {Domain}", - emailDomain); - throw new BadRequestException("This email address is claimed by an organization using Bitwarden."); - } + if (await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(emailDomain)) + { + _logger.LogInformation( + "User registration email verification blocked by domain claim policy. Domain: {Domain}", + emailDomain); + throw new BadRequestException("This email address is claimed by an organization using Bitwarden."); } // Check to see if the user already exists diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/ICompleteTwoFactorWebAuthnRegistrationCommand.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/ICompleteTwoFactorWebAuthnRegistrationCommand.cs new file mode 100644 index 0000000000..ec31419083 --- /dev/null +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/ICompleteTwoFactorWebAuthnRegistrationCommand.cs @@ -0,0 +1,18 @@ +using Bit.Core.Entities; +using Fido2NetLib; + +namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth; + +public interface ICompleteTwoFactorWebAuthnRegistrationCommand +{ + /// + /// Enshrines WebAuthn 2FA credential registration after a successful challenge. + /// + /// The current user. + /// ID for the Key credential to complete. + /// Name for the Key credential to complete. + /// WebAuthn attestation response. + /// Whether persisting the credential was successful. + Task CompleteTwoFactorWebAuthnRegistrationAsync(User user, int id, string name, + AuthenticatorAttestationRawResponse attestationResponse); +} diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/IDeleteTwoFactorWebAuthnCredentialCommand.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/IDeleteTwoFactorWebAuthnCredentialCommand.cs new file mode 100644 index 0000000000..030183ade6 --- /dev/null +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/IDeleteTwoFactorWebAuthnCredentialCommand.cs @@ -0,0 +1,19 @@ +using Bit.Core.Entities; +using Bit.Core.Services; + +namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth; + +public interface IDeleteTwoFactorWebAuthnCredentialCommand +{ + /// + /// Deletes a single Two-factor WebAuthn credential by ID ("Key{id}"). + /// + /// The current user. + /// ID of the credential to delete ("Key{id}"). + /// Whether deletion was successful. + /// Will not delete the last registered credential for a user. To delete the last (or single) + /// registered credential, use + Task DeleteTwoFactorWebAuthnCredentialAsync(User user, int id); +} + + diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/IStartTwoFactorWebAuthnRegistrationCommand.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/IStartTwoFactorWebAuthnRegistrationCommand.cs new file mode 100644 index 0000000000..13e0048b5b --- /dev/null +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/IStartTwoFactorWebAuthnRegistrationCommand.cs @@ -0,0 +1,16 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Fido2NetLib; + +namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth; + +public interface IStartTwoFactorWebAuthnRegistrationCommand +{ + /// + /// Initiates WebAuthn 2FA credential registration and generates a challenge for adding a new security key. + /// + /// The current user. + /// Options for creating a new WebAuthn 2FA credential + /// Maximum allowed number of credentials already registered. + Task StartTwoFactorWebAuthnRegistrationAsync(User user); +} diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/ITwoFactorIsEnabledQuery.cs similarity index 100% rename from src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs rename to src/Core/Auth/UserFeatures/TwoFactorAuth/ITwoFactorIsEnabledQuery.cs diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/CompleteTwoFactorWebAuthnRegistrationCommand.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/CompleteTwoFactorWebAuthnRegistrationCommand.cs new file mode 100644 index 0000000000..ae2d9f829e --- /dev/null +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/CompleteTwoFactorWebAuthnRegistrationCommand.cs @@ -0,0 +1,89 @@ +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models; +using Bit.Core.Billing.Premium.Queries; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Settings; +using Fido2NetLib; +using Fido2NetLib.Objects; + +namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations; + +public class CompleteTwoFactorWebAuthnRegistrationCommand : ICompleteTwoFactorWebAuthnRegistrationCommand +{ + private readonly IFido2 _fido2; + private readonly IGlobalSettings _globalSettings; + private readonly IHasPremiumAccessQuery _hasPremiumAccessQuery; + private readonly IUserService _userService; + + public CompleteTwoFactorWebAuthnRegistrationCommand(IFido2 fido2, + IGlobalSettings globalSettings, + IHasPremiumAccessQuery hasPremiumAccessQuery, + IUserService userService) + { + _fido2 = fido2; + _globalSettings = globalSettings; + _hasPremiumAccessQuery = hasPremiumAccessQuery; + _userService = userService; + } + + public async Task CompleteTwoFactorWebAuthnRegistrationAsync(User user, int id, string name, + AuthenticatorAttestationRawResponse attestationResponse) + { + var keyId = $"Key{id}"; + + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); + if (provider?.MetaData is null || !provider.MetaData.TryGetValue("pending", out var pendingValue)) + { + return false; + } + + // Persistence-time validation for comprehensive enforcement. There is also boundary validation for best-possible UX. + var maximumAllowedCredentialCount = await _hasPremiumAccessQuery.HasPremiumAccessAsync(user.Id) + ? _globalSettings.WebAuthn.PremiumMaximumAllowedCredentials + : _globalSettings.WebAuthn.NonPremiumMaximumAllowedCredentials; + // Count only saved credentials ("Key{id}") toward the limit. + if (provider.MetaData.Count(k => k.Key.StartsWith("Key")) >= + maximumAllowedCredentialCount) + { + throw new BadRequestException("Maximum allowed WebAuthn credential count exceeded."); + } + + var options = CredentialCreateOptions.FromJson((string)pendingValue); + + // Callback to ensure credential ID is unique. Always return true since we don't care if another + // account uses the same 2FA key. + IsCredentialIdUniqueToUserAsyncDelegate callback = (args, cancellationToken) => Task.FromResult(true); + + var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, callback); + if (success.Result == null) + { + throw new BadRequestException("WebAuthn credential creation failed."); + } + + provider.MetaData.Remove("pending"); + provider.MetaData[keyId] = new TwoFactorProvider.WebAuthnData + { + Name = name, + Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId), + PublicKey = success.Result.PublicKey, + UserHandle = success.Result.User.Id, + SignatureCounter = success.Result.Counter, + CredType = success.Result.CredType, + RegDate = DateTime.Now, + AaGuid = success.Result.Aaguid + }; + + var providers = user.GetTwoFactorProviders(); + if (providers == null) + { + throw new BadRequestException("No two-factor provider found."); + } + providers[TwoFactorProviderType.WebAuthn] = provider; + user.SetTwoFactorProviders(providers); + await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); + + return true; + } +} diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/DeleteTwoFactorWebAuthnCredentialCommand.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/DeleteTwoFactorWebAuthnCredentialCommand.cs new file mode 100644 index 0000000000..426ebc0a31 --- /dev/null +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/DeleteTwoFactorWebAuthnCredentialCommand.cs @@ -0,0 +1,44 @@ +using Bit.Core.Auth.Enums; +using Bit.Core.Entities; +using Bit.Core.Services; + +namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations; + +public class DeleteTwoFactorWebAuthnCredentialCommand : IDeleteTwoFactorWebAuthnCredentialCommand +{ + private readonly IUserService _userService; + + public DeleteTwoFactorWebAuthnCredentialCommand(IUserService userService) + { + _userService = userService; + } + public async Task DeleteTwoFactorWebAuthnCredentialAsync(User user, int id) + { + var providers = user.GetTwoFactorProviders(); + if (providers == null) + { + return false; + } + + var keyName = $"Key{id}"; + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); + if (provider?.MetaData == null || !provider.MetaData.ContainsKey(keyName)) + { + return false; + } + + // Do not delete the last registered key credential. + // This prevents accidental account lockout (factor enabled, no credentials registered). + // To remove the last (or single) registered credential, disable the WebAuthn 2fa provider. + if (provider.MetaData.Count(k => k.Key.StartsWith("Key")) < 2) + { + return false; + } + + provider.MetaData.Remove(keyName); + providers[TwoFactorProviderType.WebAuthn] = provider; + user.SetTwoFactorProviders(providers); + await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); + return true; + } +} diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/StartTwoFactorWebAuthnRegistrationCommand.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/StartTwoFactorWebAuthnRegistrationCommand.cs new file mode 100644 index 0000000000..a1736635ae --- /dev/null +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/StartTwoFactorWebAuthnRegistrationCommand.cs @@ -0,0 +1,76 @@ +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models; +using Bit.Core.Billing.Premium.Queries; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Settings; +using Fido2NetLib; +using Fido2NetLib.Objects; + +namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations; + +public class StartTwoFactorWebAuthnRegistrationCommand : IStartTwoFactorWebAuthnRegistrationCommand +{ + private readonly IFido2 _fido2; + private readonly IGlobalSettings _globalSettings; + private readonly IHasPremiumAccessQuery _hasPremiumAccessQuery; + private readonly IUserService _userService; + + public StartTwoFactorWebAuthnRegistrationCommand( + IFido2 fido2, + IGlobalSettings globalSettings, + IHasPremiumAccessQuery hasPremiumAccessQuery, + IUserService userService) + { + _fido2 = fido2; + _globalSettings = globalSettings; + _hasPremiumAccessQuery = hasPremiumAccessQuery; + _userService = userService; + } + + public async Task StartTwoFactorWebAuthnRegistrationAsync(User user) + { + var providers = user.GetTwoFactorProviders() ?? new Dictionary(); + + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn) ?? + new TwoFactorProvider { Enabled = false }; + provider.MetaData ??= new Dictionary(); + + // Boundary validation to provide a better UX. There is also second-level enforcement at persistence time. + var userHasPremiumAccess = await _hasPremiumAccessQuery.HasPremiumAccessAsync(user.Id); + var maximumAllowedCredentialCount = userHasPremiumAccess + ? _globalSettings.WebAuthn.PremiumMaximumAllowedCredentials + : _globalSettings.WebAuthn.NonPremiumMaximumAllowedCredentials; + + // Count only saved credentials ("Key{id}") toward the limit. + if (provider.MetaData.Count(k => k.Key.StartsWith("Key")) >= + maximumAllowedCredentialCount) + { + throw new BadRequestException("Maximum allowed WebAuthn credential count exceeded."); + } + + var fidoUser = new Fido2User { DisplayName = user.Name, Name = user.Email, Id = user.Id.ToByteArray(), }; + + var excludeCredentials = provider.MetaData + .Where(k => k.Key.StartsWith("Key")) + .Select(k => new TwoFactorProvider.WebAuthnData((dynamic)k.Value).Descriptor) + .ToList(); + + var authenticatorSelection = new AuthenticatorSelection + { + AuthenticatorAttachment = null, + RequireResidentKey = false, + UserVerification = UserVerificationRequirement.Discouraged + }; + var options = _fido2.RequestNewCredential(fidoUser, excludeCredentials, authenticatorSelection, + AttestationConveyancePreference.None); + + provider.MetaData["pending"] = options.ToJson(); + providers[TwoFactorProviderType.WebAuthn] = provider; + user.SetTwoFactorProviders(providers); + await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn, false); + + return options; + } +} diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/TwoFactorIsEnabledQuery.cs similarity index 99% rename from src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs rename to src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/TwoFactorIsEnabledQuery.cs index e6c0c1444a..29fe9fb0b8 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/Implementations/TwoFactorIsEnabledQuery.cs @@ -10,7 +10,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; -namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth; +namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations; public class TwoFactorIsEnabledQuery : ITwoFactorIsEnabledQuery { diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 356d5bf2bc..693b37f47c 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; @@ -30,7 +31,7 @@ public static class UserServiceCollectionExtensions services.AddUserRegistrationCommands(); services.AddWebAuthnLoginCommands(); services.AddTdeOffboardingPasswordCommands(); - services.AddTwoFactorQueries(); + services.AddTwoFactorCommandsQueries(); services.AddSsoQueries(); } @@ -75,8 +76,14 @@ public static class UserServiceCollectionExtensions services.AddScoped(); } - private static void AddTwoFactorQueries(this IServiceCollection services) + private static void AddTwoFactorCommandsQueries(this IServiceCollection services) { + services + .AddScoped(); + services + .AddScoped(); + services.AddScoped(); services.AddScoped(); } diff --git a/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs index 5734babc31..225954fb46 100644 --- a/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs +++ b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs @@ -3,6 +3,7 @@ using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Services; using Bit.Core.Settings; @@ -143,6 +144,24 @@ public class UpdatePaymentMethodCommand( await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); } + // If the subscriber has an incomplete subscription, pay the invoice with the new PayPal payment method + if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId)) + { + var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId); + + if (subscription.Status == StripeConstants.SubscriptionStatus.Incomplete) + { + var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, + new InvoiceUpdateOptions + { + AutoAdvance = false, + Expand = ["customer"] + }); + + await braintreeService.PayInvoice(new UserId(subscriber.Id), invoice); + } + } + var payPalAccount = braintreeCustomer.DefaultPaymentMethod as PayPalAccount; return MaskedPaymentMethod.From(payPalAccount!); diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index 764406ee56..7dc9067635 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -72,7 +72,13 @@ public class CreatePremiumCloudHostedSubscriptionCommand( BillingAddress billingAddress, short additionalStorageGb) => HandleAsync(async () => { - if (user.Premium) + // A "terminal" subscription is one that has ended and cannot be renewed/reactivated. + // These are: 'canceled' (user canceled) and 'incomplete_expired' (payment failed and time expired). + // We allow users with terminal subscriptions to create a new subscription even if user.Premium is still true, + // enabling the resubscribe workflow without requiring Premium status to be cleared first. + var hasTerminalSubscription = await HasTerminalSubscriptionAsync(user); + + if (user.Premium && !hasTerminalSubscription) { return new BadRequest("Already a premium user."); } @@ -98,8 +104,11 @@ public class CreatePremiumCloudHostedSubscriptionCommand( * purchased account credit but chose to use a tokenizable payment method to pay for the subscription. In this case, * we need to add the payment method to their customer first. If the incoming payment method is account credit, * we can just go straight to fetching the customer since there's no payment method to apply. + * + * Additionally, if this is a resubscribe scenario with a tokenized payment method, we should update the payment method + * to ensure the new payment method is used instead of the old one. */ - else if (paymentMethod.IsTokenized && !await hasPaymentMethodQuery.Run(user)) + else if (paymentMethod.IsTokenized && (!await hasPaymentMethodQuery.Run(user) || hasTerminalSubscription)) { await updatePaymentMethodCommand.Run(user, paymentMethod.AsTokenized, billingAddress); customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); @@ -122,7 +131,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( case { Type: TokenizablePaymentMethodType.PayPal } when subscription.Status == SubscriptionStatus.Incomplete: case { Type: not TokenizablePaymentMethodType.PayPal } - when subscription.Status == SubscriptionStatus.Active: + when subscription.Status is SubscriptionStatus.Active or SubscriptionStatus.Incomplete: { user.Premium = true; user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); @@ -369,4 +378,28 @@ public class CreatePremiumCloudHostedSubscriptionCommand( return subscription; } + + private async Task HasTerminalSubscriptionAsync(User user) + { + if (string.IsNullOrEmpty(user.GatewaySubscriptionId)) + { + return false; + } + + try + { + var existingSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId); + return existingSubscription.Status is + SubscriptionStatus.Canceled or + SubscriptionStatus.IncompleteExpired; + } + catch (Exception ex) + { + // Subscription doesn't exist in Stripe or can't be fetched (e.g., network issues, invalid ID) + // Log the issue but proceed with subscription creation to avoid blocking legitimate resubscribe attempts + _logger.LogWarning(ex, "Unable to fetch existing subscription {SubscriptionId} for user {UserId}. Proceeding with subscription creation", + user.GatewaySubscriptionId, user.Id); + return false; + } + } } diff --git a/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs b/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs index 51c51bd7b2..f1ebcfb986 100644 --- a/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs +++ b/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs @@ -31,7 +31,7 @@ public interface IGetBitwardenSubscriptionQuery /// Currently only supports subscribers. Future versions will support all /// types (User and Organization). /// - Task Run(User user); + Task Run(User user); } public class GetBitwardenSubscriptionQuery( @@ -39,8 +39,13 @@ public class GetBitwardenSubscriptionQuery( IPricingClient pricingClient, IStripeAdapter stripeAdapter) : IGetBitwardenSubscriptionQuery { - public async Task Run(User user) + public async Task Run(User user) { + if (string.IsNullOrEmpty(user.GatewaySubscriptionId)) + { + return null; + } + var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ddd88f1e83..a1c97db6be 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -156,10 +156,8 @@ public static class FeatureFlagKeys public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; - public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration"; public const string DefaultUserCollectionRestore = "pm-30883-my-items-restored-users"; public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface"; - public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance"; public const string BulkReinviteUI = "pm-28416-bulk-reinvite-ux-improvements"; /* Architecture */ @@ -223,6 +221,7 @@ public static class FeatureFlagKeys public const string DataRecoveryTool = "pm-28813-data-recovery-tool"; public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration"; public const string SdkKeyRotation = "pm-30144-sdk-key-rotation"; + public const string UnlockViaSdk = "unlock-via-sdk"; public const string EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration"; /* Mobile Team */ @@ -270,7 +269,9 @@ public static class FeatureFlagKeys public const string MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems"; public const string PM27632_CipherCrudOperationsToSdk = "pm-27632-cipher-crud-operations-to-sdk"; public const string PM30521_AutofillButtonViewLoginScreen = "pm-30521-autofill-button-view-login-screen"; + public const string PM32180_PremiumUpsellAccountAge = "pm-32180-premium-upsell-account-age"; public const string PM29438_WelcomeDialogWithExtensionPrompt = "pm-29438-welcome-dialog-with-extension-prompt"; + public const string PM31039_ItemActionInExtension = "pm-31039-item-action-in-extension"; /* Innovation Team */ public const string ArchiveVaultItems = "pm-19148-innovation-archive"; diff --git a/src/Core/MailTemplates/Mjml/.mjmlconfig b/src/Core/MailTemplates/Mjml/.mjmlconfig index a71e3b5ee9..caff8a7d40 100644 --- a/src/Core/MailTemplates/Mjml/.mjmlconfig +++ b/src/Core/MailTemplates/Mjml/.mjmlconfig @@ -4,6 +4,9 @@ "components/mj-bw-simple-hero", "components/mj-bw-icon-row", "components/mj-bw-learn-more-footer", - "emails/AdminConsole/components/mj-bw-inviter-info" + "emails/AdminConsole/components/mj-bw-inviter-info", + "emails/AdminConsole/components/mj-bw-ac-hero", + "emails/AdminConsole/components/mj-bw-ac-icon-row", + "emails/AdminConsole/components/mj-bw-ac-learn-more-footer" ] } diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml index b94bf0dc86..02c9eea37d 100644 --- a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml @@ -1,50 +1,51 @@ - - - + + + - - - - - - - - - - - As a member of {{OrganizationName}}: - - - - - - - - + - - - - + + + + + - - - + + + + + + As a member of {{OrganizationName}}: + + + + + + + + + + + + + + + diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml index c223e2f650..cba02314c8 100644 --- a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml @@ -1,55 +1,104 @@ - - - + + + - - - - - + + - - - - - - As a member of {{OrganizationName}}: - - - - - - - - + + + + + + - - - - + + + + + + As a member of {{OrganizationName}}: + + + + + + + + - - - - + + + + + + Download Bitwarden on all devices + - - - + + Already using the browser extension? + Download the Bitwarden mobile app from the + App Store + or Google Play + to quickly save logins and autofill forms on the go. + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/components/admin-console-head.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/components/admin-console-head.mjml new file mode 100644 index 0000000000..2b70f5718c --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/components/admin-console-head.mjml @@ -0,0 +1,17 @@ + + + + + + + + @media only screen and (max-width: 480px) { + .hide-mobile { display: none !important; } + } + diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/components/mj-bw-ac-hero.js b/src/Core/MailTemplates/Mjml/emails/AdminConsole/components/mj-bw-ac-hero.js new file mode 100644 index 0000000000..a8c5158982 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/components/mj-bw-ac-hero.js @@ -0,0 +1,92 @@ +const { BodyComponent } = require("mjml-core"); +class MjBwAcHero extends BodyComponent { + static dependencies = { + // Tell the validator which tags are allowed as our component's parent + "mj-column": ["mj-bw-ac-hero"], + "mj-wrapper": ["mj-bw-ac-hero"], + // Tell the validator which tags are allowed as our component's children + "mj-bw-ac-hero": [], + }; + + static allowedAttributes = { + "img-src": "string", // REQUIRED: Source for the image displayed in the right-hand side of the blue header area + title: "string", // REQUIRED: large text stating primary purpose of the email + "button-text": "string", // OPTIONAL: text to display in the button + "button-url": "string", // OPTIONAL: URL to navigate to when the button is clicked + "sub-title": "string", // OPTIONAL: smaller text providing additional context for the title + }; + + static defaultAttributes = {}; + + componentHeadStyle = breakpoint => { + return ` + @media only screen and (max-width:${breakpoint}) { + .mj-bw-ac-hero-responsive-img { + display: none !important; + } + } + ` + } + + render() { + const buttonElement = this.getAttribute("button-text") && this.getAttribute("button-url") ? + ` + ${this.getAttribute("button-text")} + ` : ""; + const subTitleElement = this.getAttribute("sub-title") ? + ` +

    + ${this.getAttribute("sub-title")} +

    +
    ` : ""; + + return this.renderMJML( + ` + + + + +

    + ${this.getAttribute("title")} +

    + ` + + subTitleElement + + ` +
    ` + + buttonElement + + ` +
    + + + +
    + `, + ); + } +} + +module.exports = MjBwAcHero; \ No newline at end of file diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/components/mj-bw-ac-icon-row.js b/src/Core/MailTemplates/Mjml/emails/AdminConsole/components/mj-bw-ac-icon-row.js new file mode 100644 index 0000000000..8d3ac80a2a --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/components/mj-bw-ac-icon-row.js @@ -0,0 +1,103 @@ +const { BodyComponent } = require("mjml-core"); + +const BODY_TEXT_STYLES = ` + font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" + font-size="16px" + font-weight="400" + line-height="24px" +`; + +class MjBwAcIconRow extends BodyComponent { + static dependencies = { + "mj-column": ["mj-bw-ac-icon-row"], + "mj-wrapper": ["mj-bw-ac-icon-row"], + "mj-bw-ac-icon-row": [], + }; + + static allowedAttributes = { + "icon-src": "string", + "icon-alt": "string", + "head-url-text": "string", + "head-url": "string", + text: "string", + "foot-url-text": "string", + "foot-url": "string", + }; + + static defaultAttributes = {}; + + headStyle = (breakpoint) => { + return ` + @media only screen and (max-width:${breakpoint}) { + .mj-bw-ac-icon-row-text { + padding-left: 15px !important; + padding-right: 15px !important; + line-height: 20px; + } + .mj-bw-ac-icon-row-icon { + display: none !important; + width: 0 !important; + max-width: 0 !important; + } + .mj-bw-ac-icon-row-text-column { + width: 100% !important; + } + } + `; + }; + + render() { + const headAnchorElement = + this.getAttribute("head-url-text") && this.getAttribute("head-url") + ? ` + + + ${this.getAttribute("head-url-text")} + + External Link Icon + + + ` + : ""; + + const footAnchorElement = + this.getAttribute("foot-url-text") && this.getAttribute("foot-url") + ? ` + + ${this.getAttribute("foot-url-text")} + + ` + : ""; + + return this.renderMJML( + ` + + + + + + + ${headAnchorElement} + + ${this.getAttribute("text")} + + ${footAnchorElement} + + + + `, + ); + } +} + +module.exports = MjBwAcIconRow; \ No newline at end of file diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/components/mj-bw-ac-learn-more-footer.js b/src/Core/MailTemplates/Mjml/emails/AdminConsole/components/mj-bw-ac-learn-more-footer.js new file mode 100644 index 0000000000..273328804a --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/components/mj-bw-ac-learn-more-footer.js @@ -0,0 +1,55 @@ +const { BodyComponent } = require("mjml-core"); +class MjBwAcLearnMoreFooter extends BodyComponent { + static dependencies = { + // Tell the validator which tags are allowed as our component's parent + "mj-column": ["mj-bw-ac-learn-more-footer"], + "mj-wrapper": ["mj-bw-ac-learn-more-footer"], + // Tell the validator which tags are allowed as our component's children + "mj-bw-ac-learn-more-footer": [], + }; + + static allowedAttributes = {}; + + static defaultAttributes = {}; + + componentHeadStyle = (breakpoint) => { + return ` + @media only screen and (max-width:${breakpoint}) { + .mj-bw-ac-learn-more-footer-responsive-img { + display: none !important; + } + } + `; + }; + + render() { + return this.renderMJML( + ` + + + +

    + Learn more about Bitwarden +

    +

    + Find user guides, product documentation, and videos on the + Bitwarden Help Center. +

    +
    +
    + + + +
    + `, + ); + } +} + +module.exports = MjBwAcLearnMoreFooter; \ No newline at end of file diff --git a/src/Core/Models/PushNotification.cs b/src/Core/Models/PushNotification.cs index ec39c495aa..18245faf48 100644 --- a/src/Core/Models/PushNotification.cs +++ b/src/Core/Models/PushNotification.cs @@ -110,3 +110,21 @@ public class SyncPolicyPushNotification public Guid OrganizationId { get; set; } public required Policy Policy { get; set; } } + +public class AutoConfirmPushNotification +{ + /// + /// The admin/owner receiving this notification + /// + public Guid UserId { get; set; } + + /// + /// The organization the user accepted an invite to + /// + public Guid OrganizationId { get; set; } + + /// + /// The user who accepted the organization invite (will be auto-confirmed) + /// + public Guid TargetUserId { get; set; } +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index c1ebc65d44..6fb1145e7c 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -197,6 +197,7 @@ public static class OrganizationServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Core/Platform/Push/PushType.cs b/src/Core/Platform/Push/PushType.cs index 9a601ab0d3..b086195304 100644 --- a/src/Core/Platform/Push/PushType.cs +++ b/src/Core/Platform/Push/PushType.cs @@ -99,4 +99,7 @@ public enum PushType : byte [NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.SyncPolicyPushNotification))] PolicyChanged = 25, + + [NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.AutoConfirmPushNotification))] + AutoConfirm = 26, } diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index a531883db1..68392bb80c 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -8,7 +8,6 @@ using Bit.Core.Billing.Models.Business; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; -using Fido2NetLib; using Microsoft.AspNetCore.Identity; namespace Bit.Core.Services; @@ -24,9 +23,6 @@ public interface IUserService Task CreateUserAsync(User user); Task CreateUserAsync(User user, string masterPasswordHash); Task SendMasterPasswordHintAsync(string email); - Task StartWebAuthnRegistrationAsync(User user); - Task DeleteWebAuthnKeyAsync(User user, int id); - Task CompleteWebAuthRegistrationAsync(User user, int value, string name, AuthenticatorAttestationRawResponse attestationResponse); Task SendEmailVerificationAsync(User user); Task ConfirmEmailAsync(User user, string token); Task InitiateEmailChangeAsync(User user, string newEmail); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 5f87ee85d2..95896d710e 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -12,7 +12,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; -using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Licenses; using Bit.Core.Billing.Licenses.Extensions; @@ -35,7 +34,6 @@ using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Utilities; using Fido2NetLib; -using Fido2NetLib.Objects; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; @@ -346,148 +344,6 @@ public class UserService : UserManager, IUserService await _mailService.SendMasterPasswordHintEmailAsync(email, user.MasterPasswordHint); } - /// - /// Initiates WebAuthn 2FA credential registration and generates a challenge for adding a new security key. - /// - /// The current user. - /// - /// Maximum allowed number of credentials already registered. - public async Task StartWebAuthnRegistrationAsync(User user) - { - var providers = user.GetTwoFactorProviders(); - if (providers == null) - { - providers = new Dictionary(); - } - var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); - if (provider == null) - { - provider = new TwoFactorProvider - { - Enabled = false - }; - } - if (provider.MetaData == null) - { - provider.MetaData = new Dictionary(); - } - - // Boundary validation to provide a better UX. There is also second-level enforcement at persistence time. - var maximumAllowedCredentialCount = await _hasPremiumAccessQuery.HasPremiumAccessAsync(user.Id) - ? _globalSettings.WebAuthn.PremiumMaximumAllowedCredentials - : _globalSettings.WebAuthn.NonPremiumMaximumAllowedCredentials; - // Count only saved credentials ("Key{id}") toward the limit. - if (provider.MetaData.Count(k => k.Key.StartsWith("Key")) >= - maximumAllowedCredentialCount) - { - throw new BadRequestException("Maximum allowed WebAuthn credential count exceeded."); - } - - var fidoUser = new Fido2User - { - DisplayName = user.Name, - Name = user.Email, - Id = user.Id.ToByteArray(), - }; - - var excludeCredentials = provider.MetaData - .Where(k => k.Key.StartsWith("Key")) - .Select(k => new TwoFactorProvider.WebAuthnData((dynamic)k.Value).Descriptor) - .ToList(); - - var authenticatorSelection = new AuthenticatorSelection - { - AuthenticatorAttachment = null, - RequireResidentKey = false, - UserVerification = UserVerificationRequirement.Discouraged - }; - var options = _fido2.RequestNewCredential(fidoUser, excludeCredentials, authenticatorSelection, AttestationConveyancePreference.None); - - provider.MetaData["pending"] = options.ToJson(); - providers[TwoFactorProviderType.WebAuthn] = provider; - user.SetTwoFactorProviders(providers); - await UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn, false); - - return options; - } - - public async Task CompleteWebAuthRegistrationAsync(User user, int id, string name, AuthenticatorAttestationRawResponse attestationResponse) - { - var keyId = $"Key{id}"; - - var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); - if (provider?.MetaData is null || !provider.MetaData.TryGetValue("pending", out var pendingValue)) - { - return false; - } - - // Persistence-time validation for comprehensive enforcement. There is also boundary validation for best-possible UX. - var maximumAllowedCredentialCount = await _hasPremiumAccessQuery.HasPremiumAccessAsync(user.Id) - ? _globalSettings.WebAuthn.PremiumMaximumAllowedCredentials - : _globalSettings.WebAuthn.NonPremiumMaximumAllowedCredentials; - // Count only saved credentials ("Key{id}") toward the limit. - if (provider.MetaData.Count(k => k.Key.StartsWith("Key")) >= - maximumAllowedCredentialCount) - { - throw new BadRequestException("Maximum allowed WebAuthn credential count exceeded."); - } - - var options = CredentialCreateOptions.FromJson((string)pendingValue); - - // Callback to ensure credential ID is unique. Always return true since we don't care if another - // account uses the same 2FA key. - IsCredentialIdUniqueToUserAsyncDelegate callback = (args, cancellationToken) => Task.FromResult(true); - - var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, callback); - - provider.MetaData.Remove("pending"); - provider.MetaData[keyId] = new TwoFactorProvider.WebAuthnData - { - Name = name, - Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId), - PublicKey = success.Result.PublicKey, - UserHandle = success.Result.User.Id, - SignatureCounter = success.Result.Counter, - CredType = success.Result.CredType, - RegDate = DateTime.Now, - AaGuid = success.Result.Aaguid - }; - - var providers = user.GetTwoFactorProviders(); - providers[TwoFactorProviderType.WebAuthn] = provider; - user.SetTwoFactorProviders(providers); - await UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); - - return true; - } - - public async Task DeleteWebAuthnKeyAsync(User user, int id) - { - var providers = user.GetTwoFactorProviders(); - if (providers == null) - { - return false; - } - - var keyName = $"Key{id}"; - var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); - if (!provider?.MetaData?.ContainsKey(keyName) ?? true) - { - return false; - } - - if (provider.MetaData.Count < 2) - { - return false; - } - - provider.MetaData.Remove(keyName); - providers[TwoFactorProviderType.WebAuthn] = provider; - user.SetTwoFactorProviders(providers); - await UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); - return true; - } - public async Task SendEmailVerificationAsync(User user) { if (user.EmailVerified) diff --git a/src/Notifications/HubHelpers.cs b/src/Notifications/HubHelpers.cs index bc03bb46df..f9956a5de4 100644 --- a/src/Notifications/HubHelpers.cs +++ b/src/Notifications/HubHelpers.cs @@ -234,6 +234,18 @@ public class HubHelpers case PushType.PolicyChanged: await policyChangedNotificationHandler(notificationJson, cancellationToken); break; + case PushType.AutoConfirm: + var autoConfirmNotification = + JsonSerializer.Deserialize>( + notificationJson, _deserializerOptions); + if (autoConfirmNotification is null) + { + break; + } + + await _hubContext.Clients.User(autoConfirmNotification.Payload.UserId.ToString()) + .SendAsync(_receiveMessageMethod, autoConfirmNotification, cancellationToken); + break; default: _logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type); break; diff --git a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 84ef5c7f3d..7e56a28577 100644 --- a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -5,6 +5,7 @@ using Bit.Admin.Services; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Providers.Services; @@ -12,7 +13,11 @@ using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; using NSubstitute; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; namespace Admin.Test.AdminConsole.Controllers; @@ -299,18 +304,164 @@ public class OrganizationsControllerTests .Returns(true); var organizationRepository = sutProvider.GetDependency(); - organizationRepository.GetByIdAsync(organization.Id).Returns(organization); + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organization.Id); + sutProvider.GetDependency() + .IsOrganizationCompliantAsync(Arg.Any()) + .Returns(Valid(request)); + // Act _ = await sutProvider.Sut.Edit(organization.Id, update); // Assert await organizationRepository.Received(1).ReplaceAsync(Arg.Is(o => o.Id == organization.Id && o.UseAutomaticUserConfirmation == true)); + } - // Annul - await organizationRepository.DeleteAsync(organization); + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_EnableUseAutomaticUserConfirmation_ValidationFails_RedirectsWithError( + Organization organization, + SutProvider sutProvider) + { + // Arrange + var update = new OrganizationEditModel + { + PlanType = PlanType.TeamsMonthly, + UseAutomaticUserConfirmation = true + }; + + organization.UseAutomaticUserConfirmation = false; + + sutProvider.GetDependency() + .UserHasPermission(Permission.Org_Plan_Edit) + .Returns(true); + + var organizationRepository = sutProvider.GetDependency(); + organizationRepository.GetByIdAsync(organization.Id).Returns(organization); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organization.Id); + sutProvider.GetDependency() + .IsOrganizationCompliantAsync(Arg.Any()) + .Returns(Invalid(request, new UserNotCompliantWithSingleOrganization())); + + sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For()); + + // Act + var result = await sutProvider.Sut.Edit(organization.Id, update); + + // Assert + var redirectResult = Assert.IsType(result); + Assert.Equal("Edit", redirectResult.ActionName); + Assert.Equal(organization.Id, redirectResult.RouteValues!["id"]); + + await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_EnableUseAutomaticUserConfirmation_ProviderValidationFails_RedirectsWithError( + Organization organization, + SutProvider sutProvider) + { + // Arrange + var update = new OrganizationEditModel + { + PlanType = PlanType.TeamsMonthly, + UseAutomaticUserConfirmation = true + }; + + organization.UseAutomaticUserConfirmation = false; + + sutProvider.GetDependency() + .UserHasPermission(Permission.Org_Plan_Edit) + .Returns(true); + + var organizationRepository = sutProvider.GetDependency(); + organizationRepository.GetByIdAsync(organization.Id).Returns(organization); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organization.Id); + sutProvider.GetDependency() + .IsOrganizationCompliantAsync(Arg.Any()) + .Returns(Invalid(request, new ProviderExistsInOrganization())); + + sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For()); + + // Act + var result = await sutProvider.Sut.Edit(organization.Id, update); + + // Assert + var redirectResult = Assert.IsType(result); + Assert.Equal("Edit", redirectResult.ActionName); + + await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_UseAutomaticUserConfirmation_NotChanged_DoesNotCallValidator( + SutProvider sutProvider) + { + // Arrange + var organizationId = new Guid(); + var update = new OrganizationEditModel + { + UseSecretsManager = false, + UseAutomaticUserConfirmation = false + }; + + var organization = new Organization + { + Id = organizationId, + UseAutomaticUserConfirmation = false + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(organization); + + // Act + _ = await sutProvider.Sut.Edit(organizationId, update); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .IsOrganizationCompliantAsync(Arg.Any()); + } + + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_UseAutomaticUserConfirmation_AlreadyEnabled_DoesNotCallValidator( + SutProvider sutProvider) + { + // Arrange + var organizationId = new Guid(); + var update = new OrganizationEditModel + { + UseSecretsManager = false, + UseAutomaticUserConfirmation = true + }; + + var organization = new Organization + { + Id = organizationId, + UseAutomaticUserConfirmation = true + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(organization); + + // Act + _ = await sutProvider.Sut.Edit(organizationId, update); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .IsOrganizationCompliantAsync(Arg.Any()); } #endregion diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerAutoConfirmTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerAutoConfirmTests.cs index 8df1fcaf2b..96da60d6bd 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerAutoConfirmTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerAutoConfirmTests.cs @@ -162,7 +162,7 @@ public class OrganizationUserControllerAutoConfirmTests : IClassFixture().CreateAsync(new Policy @@ -190,15 +190,17 @@ public class OrganizationUserControllerAutoConfirmTests : IClassFixture _client.PostAsJsonAsync($"organizations/{organization.Id}/users/{organizationUser.Id}/auto-confirm", + var results = new List(); + + foreach (var _ in Enumerable.Range(0, 10)) + { + results.Add(await _client.PostAsJsonAsync($"organizations/{organization.Id}/users/{organizationUser.Id}/auto-confirm", new OrganizationUserConfirmRequestModel { Key = testKey, DefaultUserCollectionName = _mockEncryptedString - })).ToList(); - - var results = await Task.WhenAll(tenRequests); + })); + } Assert.Contains(results, r => r.StatusCode == HttpStatusCode.NoContent); diff --git a/test/Api.Test/Controllers/SsoCookieVendorControllerTests.cs b/test/Api.Test/Platform/SsoCookieVendor/Controllers/SsoCookieVendorControllerTests.cs similarity index 96% rename from test/Api.Test/Controllers/SsoCookieVendorControllerTests.cs rename to test/Api.Test/Platform/SsoCookieVendor/Controllers/SsoCookieVendorControllerTests.cs index 1e954e68ff..9cc23d7048 100644 --- a/test/Api.Test/Controllers/SsoCookieVendorControllerTests.cs +++ b/test/Api.Test/Platform/SsoCookieVendor/Controllers/SsoCookieVendorControllerTests.cs @@ -1,13 +1,13 @@ #nullable enable -using Bit.Api.Controllers; +using Bit.Api.Platform.SsoCookieVendor; using Bit.Core.Settings; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using NSubstitute; using Xunit; -namespace Bit.Api.Test.Controllers; +namespace Bit.Api.Test.Platform.SsoCookieVendor.Controllers; public class SsoCookieVendorControllerTests : IDisposable { @@ -129,7 +129,7 @@ public class SsoCookieVendorControllerTests : IDisposable // Assert var redirectResult = Assert.IsType(result); - Assert.Equal("bitwarden://sso_cookie_vendor?test-cookie=my-token-value-123&d=1", redirectResult.Url); + Assert.Equal("bitwarden://sso-cookie-vendor?test-cookie=my-token-value-123&d=1", redirectResult.Url); } [Fact] @@ -170,7 +170,7 @@ public class SsoCookieVendorControllerTests : IDisposable // Assert var redirectResult = Assert.IsType(result); - Assert.StartsWith("bitwarden://sso_cookie_vendor?", redirectResult.Url); + Assert.StartsWith("bitwarden://sso-cookie-vendor?", redirectResult.Url); Assert.Contains("test-cookie-0=part1", redirectResult.Url); Assert.Contains("test-cookie-1=part2", redirectResult.Url); Assert.Contains("test-cookie-2=part3", redirectResult.Url); @@ -256,7 +256,7 @@ public class SsoCookieVendorControllerTests : IDisposable public void Get_WhenUriExceedsMaxLength_Returns400() { // Arrange - create a very long cookie value that will exceed 8192 characters - // URI format: "bitwarden://sso_cookie_vendor?test-cookie={value}" + // URI format: "bitwarden://sso-cookie-vendor?test-cookie={value}" // Base URI length is about 43 characters, so we need value > 8149 var longValue = new string('a', 8200); var cookies = new Dictionary @@ -289,7 +289,7 @@ public class SsoCookieVendorControllerTests : IDisposable // Assert var redirectResult = Assert.IsType(result); - Assert.Equal("bitwarden://sso_cookie_vendor?test-cookie=single-value&d=1", redirectResult.Url); + Assert.Equal("bitwarden://sso-cookie-vendor?test-cookie=single-value&d=1", redirectResult.Url); } [Fact] diff --git a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs index 3d77ac2343..6ca9aebc39 100644 --- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs @@ -6,6 +6,7 @@ using Bit.Api.Tools.Controllers; using Bit.Api.Tools.Models; using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Response; +using Bit.Core; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Platform.Push; @@ -80,6 +81,8 @@ public class SendsControllerTests : IDisposable send.Id = default; send.Type = SendType.Text; send.Data = JsonSerializer.Serialize(new Dictionary()); + send.AuthType = AuthType.None; + send.Emails = null; send.HideEmail = true; _sendRepository.GetByIdAsync(Arg.Any()).Returns(send); @@ -657,7 +660,7 @@ public class SendsControllerTests : IDisposable } [Theory, AutoData] - public async Task Put_WithoutPasswordOrEmails_PreservesExistingPassword(Guid userId, Guid sendId) + public async Task Put_WithoutPasswordOrEmails_ClearsExistingPassword(Guid userId, Guid sendId) { _userService.GetProperUserId(Arg.Any()).Returns(userId); var existingSend = new Send @@ -685,13 +688,13 @@ public class SendsControllerTests : IDisposable Assert.Equal(sendId, result.Id); await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => s.Id == sendId && - s.AuthType == AuthType.Password && - s.Password == "hashed-password" && + s.AuthType == AuthType.None && + s.Password == null && s.Emails == null)); } [Theory, AutoData] - public async Task Put_WithoutPasswordOrEmails_PreservesExistingEmails(Guid userId, Guid sendId) + public async Task Put_WithoutPasswordOrEmails_ClearsExistingEmails(Guid userId, Guid sendId) { _userService.GetProperUserId(Arg.Any()).Returns(userId); var existingSend = new Send @@ -719,9 +722,9 @@ public class SendsControllerTests : IDisposable Assert.Equal(sendId, result.Id); await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => s.Id == sendId && - s.AuthType == AuthType.Email && - s.Emails == "test@example.com" && - s.Password == null)); + s.AuthType == AuthType.None && + s.Password == null && + s.Emails == null)); } [Theory, AutoData] @@ -793,6 +796,33 @@ public class SendsControllerTests : IDisposable await _userService.Received(1).GetUserByIdAsync(creator.Id); } + [Theory, AutoData] + public async Task AccessUsingAuth_WithEmailProtectedSend_WithFfDisabled_ReturnsUnauthorizedResult(Guid sendId, User creator) + { + var send = new Send + { + Id = sendId, + UserId = creator.Id, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + HideEmail = false, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + AuthType = AuthType.Email, + Emails = "test@example.com", + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + _userService.GetUserByIdAsync(creator.Id).Returns(creator); + _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP).Returns(false); + + await Assert.ThrowsAsync(() => _sut.AccessUsingAuth()); + } + [Theory, AutoData] public async Task AccessUsingAuth_WithHideEmail_DoesNotIncludeCreatorIdentifier(Guid sendId, User creator) { @@ -1036,6 +1066,33 @@ public class SendsControllerTests : IDisposable await _nonAnonymousSendCommand.Received(1).GetSendFileDownloadUrlAsync(send, fileId); } + [Theory, AutoData] + public async Task GetSendFileDownloadDataUsingAuth_WithEmailProtectedSend_WithFfDisabled_ReturnsUnauthorizedResult( + Guid sendId, string fileId, string expectedUrl) + { + var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = fileId, Size = 2048 }; + var send = new Send + { + Id = sendId, + Type = SendType.File, + Data = JsonSerializer.Serialize(fileData), + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + AuthType = AuthType.Email, + Emails = "test@example.com", + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl); + _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP).Returns(false); + + await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId)); + } + [Theory, AutoData] public async Task GetSendFileDownloadDataUsingAuth_WithNonExistentSend_ThrowsBadRequestException( Guid sendId, string fileId) diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 1807050b31..9517802f13 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -315,14 +315,14 @@ public class SubscriptionUpdatedHandlerTests } [Fact] - public async Task HandleAsync_ProviderSubscription_WithIncompleteExpiredStatus_DoesNotDisableProvider() + public async Task HandleAsync_IncompleteToIncompleteExpiredTransition_DisablesProviderAndSetsCancellation() { // Arrange var providerId = Guid.NewGuid(); var subscriptionId = "sub_123"; var currentPeriodEnd = DateTime.UtcNow.AddDays(30); - // Previous status that doesn't trigger enable/disable logic + // Previous status was Incomplete - this is the valid transition for IncompleteExpired var previousSubscription = new Subscription { Id = subscriptionId, @@ -341,7 +341,7 @@ public class SubscriptionUpdatedHandlerTests ] }, Metadata = new Dictionary { { "providerId", providerId.ToString() } }, - LatestInvoice = new Invoice { BillingReason = "renewal" } + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCreate } }; var provider = new Provider { Id = providerId, Name = "Test Provider", Enabled = true }; @@ -364,10 +364,142 @@ public class SubscriptionUpdatedHandlerTests // Act await _sut.HandleAsync(parsedEvent); - // Assert - IncompleteExpired status is not handled by the new logic - Assert.True(provider.Enabled); - await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); - await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); + // Assert - Incomplete to IncompleteExpired should trigger disable and cancellation + Assert.False(provider.Enabled); + await _providerService.Received(1).UpdateAsync(provider); + await _stripeFacade.Received(1).UpdateSubscription( + subscriptionId, + Arg.Is(options => + options.CancelAt.HasValue && + options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) && + options.ProrationBehavior == ProrationBehavior.None && + options.CancellationDetails != null && + options.CancellationDetails.Comment != null)); + } + + [Fact] + public async Task HandleAsync_IncompleteToIncompleteExpiredUserSubscription_DisablesPremiumAndSetsCancellation() + { + // Arrange + var userId = Guid.NewGuid(); + var subscriptionId = "sub_123"; + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var previousSubscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Incomplete + }; + + var subscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.IncompleteExpired, + Metadata = new Dictionary { { "userId", userId.ToString() } }, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + ] + }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCreate } + }; + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userService.Received(1).DisablePremiumAsync(userId, currentPeriodEnd); + await _stripeFacade.Received(1).UpdateSubscription( + subscriptionId, + Arg.Is(options => + options.CancelAt.HasValue && + options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) && + options.ProrationBehavior == ProrationBehavior.None && + options.CancellationDetails != null && + options.CancellationDetails.Comment != null)); + } + + [Fact] + public async Task HandleAsync_IncompleteToIncompleteExpiredOrganizationSubscription_DisablesOrganizationAndSetsCancellation() + { + // Arrange + var organizationId = Guid.NewGuid(); + var subscriptionId = "sub_123"; + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var previousSubscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.Incomplete + }; + + var subscription = new Subscription + { + Id = subscriptionId, + Status = SubscriptionStatus.IncompleteExpired, + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = currentPeriodEnd, + Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } + } + ] + }, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, + LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCreate } + }; + + var organization = new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually2023 }; + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(previousSubscription) + } + }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var plan = new Enterprise2023Plan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + _pricingClient.ListPlans().Returns(MockPlans.Plans); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationDisableCommand.Received(1).DisableAsync(organizationId, currentPeriodEnd); + await _pushNotificationAdapter.Received(1).NotifyEnabledChangedAsync(organization); + await _stripeFacade.Received(1).UpdateSubscription( + subscriptionId, + Arg.Is(options => + options.CancelAt.HasValue && + options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) && + options.ProrationBehavior == ProrationBehavior.None && + options.CancellationDetails != null && + options.CancellationDetails.Comment != null)); } [Fact] @@ -470,6 +602,9 @@ public class SubscriptionUpdatedHandlerTests _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); + // Act await _sut.HandleAsync(parsedEvent); @@ -484,6 +619,10 @@ public class SubscriptionUpdatedHandlerTests options.ProrationBehavior == ProrationBehavior.None && options.CancellationDetails != null && options.CancellationDetails.Comment != null)); + await _stripeFacade.DidNotReceive() + .CancelSubscription(Arg.Any(), Arg.Any()); + await _stripeFacade.DidNotReceive() + .ListInvoices(Arg.Any()); } [Fact] @@ -527,6 +666,9 @@ public class SubscriptionUpdatedHandlerTests _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); + // Act await _sut.HandleAsync(parsedEvent); @@ -534,6 +676,10 @@ public class SubscriptionUpdatedHandlerTests await _userService.DidNotReceive().DisablePremiumAsync(Arg.Any(), Arg.Any()); await _userService.Received(1).UpdatePremiumExpirationAsync(userId, currentPeriodEnd); await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); + await _stripeFacade.DidNotReceive() + .CancelSubscription(Arg.Any(), Arg.Any()); + await _stripeFacade.DidNotReceive() + .ListInvoices(Arg.Any()); } [Fact] diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs index 82d4eceaed..2acbd67465 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -767,6 +768,50 @@ public class AcceptOrgUserCommandTests } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUser_WithAutoConfirmFeatureFlagEnabled_SendsPushNotification( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user))); + + await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService); + + await sutProvider.GetDependency() + .Received(1) + .PushAsync(user.Id, orgUser.OrganizationId); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUser_WithAutoConfirmFeatureFlagDisabled_DoesNotSendPushNotification( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(false); + + await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushAsync(Arg.Any(), Arg.Any()); + } + + private void SetupCommonAcceptOrgUserByTokenMocks(SutProvider sutProvider, User user, OrganizationUser orgUser) { sutProvider.GetDependency().OrganizationInviteExpirationHours.Returns(24); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PushAutoConfirmNotificationCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PushAutoConfirmNotificationCommandTests.cs new file mode 100644 index 0000000000..a5cfa7a602 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/PushAutoConfirmNotificationCommandTests.cs @@ -0,0 +1,286 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +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 PushAutoConfirmNotificationCommandTests +{ + [Theory] + [BitAutoData] + public async Task PushAsync_SendsNotificationToAdminsAndOwners( + SutProvider sutProvider, + Guid userId, + Guid organizationId, + OrganizationUser orgUser, + List admins) + { + foreach (var admin in admins) + { + admin.UserId = Guid.NewGuid(); + } + + orgUser.Id = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns(orgUser); + + sutProvider.GetDependency() + .GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin) + .Returns(admins); + + sutProvider.GetDependency() + .GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom) + .Returns(new List()); + + await sutProvider.Sut.PushAsync(userId, organizationId); + + await sutProvider.GetDependency() + .Received(admins.Count) + .PushAsync(Arg.Is>(pn => + pn.Type == PushType.AutoConfirm && + pn.Target == NotificationTarget.User && + pn.Payload.OrganizationId == organizationId && + pn.Payload.TargetUserId == orgUser.Id && + pn.ExcludeCurrentContext == false)); + } + + [Theory] + [BitAutoData] + public async Task PushAsync_SendsNotificationToCustomUsersWithManageUsersPermission( + SutProvider sutProvider, + Guid userId, + Guid organizationId, + OrganizationUser orgUser, + List customUsers) + { + foreach (var customUser in customUsers) + { + customUser.UserId = Guid.NewGuid(); + customUser.Permissions = "{\"manageUsers\":true}"; + } + + orgUser.Id = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns(orgUser); + + sutProvider.GetDependency() + .GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin) + .Returns(new List()); + + sutProvider.GetDependency() + .GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom) + .Returns(customUsers); + + await sutProvider.Sut.PushAsync(userId, organizationId); + + await sutProvider.GetDependency() + .Received(customUsers.Count) + .PushAsync(Arg.Is>(pn => + pn.Type == PushType.AutoConfirm && + pn.Target == NotificationTarget.User && + pn.Payload.OrganizationId == organizationId && + pn.Payload.TargetUserId == orgUser.Id && + pn.ExcludeCurrentContext == false)); + } + + [Theory] + [BitAutoData] + public async Task PushAsync_DoesNotSendToCustomUsersWithoutManageUsersPermission( + SutProvider sutProvider, + Guid userId, + Guid organizationId, + OrganizationUser orgUser, + List customUsers) + { + foreach (var customUser in customUsers) + { + customUser.UserId = Guid.NewGuid(); + customUser.Permissions = "{\"manageUsers\":false}"; + } + + orgUser.Id = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns(orgUser); + + sutProvider.GetDependency() + .GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin) + .Returns(new List()); + + sutProvider.GetDependency() + .GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom) + .Returns(customUsers); + + await sutProvider.Sut.PushAsync(userId, organizationId); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushAsync(Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task PushAsync_SendsToAdminsAndCustomUsersWithManageUsers( + SutProvider sutProvider, + Guid userId, + Guid organizationId, + OrganizationUser orgUser, + List admins, + List customUsersWithPermission, + List customUsersWithoutPermission) + { + foreach (var admin in admins) + { + admin.UserId = Guid.NewGuid(); + } + + foreach (var customUser in customUsersWithPermission) + { + customUser.UserId = Guid.NewGuid(); + customUser.Permissions = "{\"manageUsers\":true}"; + } + + foreach (var customUser in customUsersWithoutPermission) + { + customUser.UserId = Guid.NewGuid(); + customUser.Permissions = "{\"manageUsers\":false}"; + } + + orgUser.Id = Guid.NewGuid(); + + var allCustomUsers = customUsersWithPermission.Concat(customUsersWithoutPermission).ToList(); + + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns(orgUser); + + sutProvider.GetDependency() + .GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin) + .Returns(admins); + + sutProvider.GetDependency() + .GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom) + .Returns(allCustomUsers); + + await sutProvider.Sut.PushAsync(userId, organizationId); + + var expectedNotificationCount = admins.Count + customUsersWithPermission.Count; + await sutProvider.GetDependency() + .Received(expectedNotificationCount) + .PushAsync(Arg.Is>(pn => + pn.Type == PushType.AutoConfirm && + pn.Target == NotificationTarget.User && + pn.Payload.OrganizationId == organizationId && + pn.Payload.TargetUserId == orgUser.Id && + pn.ExcludeCurrentContext == false)); + } + + [Theory] + [BitAutoData] + public async Task PushAsync_SkipsUsersWithoutUserId( + SutProvider sutProvider, + Guid userId, + Guid organizationId, + OrganizationUser orgUser, + List admins) + { + admins[0].UserId = Guid.NewGuid(); + admins[1].UserId = null; + admins[2].UserId = Guid.NewGuid(); + + orgUser.Id = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns(orgUser); + + sutProvider.GetDependency() + .GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin) + .Returns(admins); + + sutProvider.GetDependency() + .GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom) + .Returns(new List()); + + await sutProvider.Sut.PushAsync(userId, organizationId); + + await sutProvider.GetDependency() + .Received(2) + .PushAsync(Arg.Is>(pn => + pn.Type == PushType.AutoConfirm)); + } + + [Theory] + [BitAutoData] + public async Task PushAsync_DeduplicatesUserIds( + SutProvider sutProvider, + Guid userId, + Guid organizationId, + OrganizationUser orgUser, + Guid duplicateUserId) + { + var admin1 = new OrganizationUserUserDetails { UserId = duplicateUserId }; + var admin2 = new OrganizationUserUserDetails { UserId = duplicateUserId }; + var customUser = new OrganizationUserUserDetails + { + UserId = duplicateUserId, + Permissions = "{\"manageUsers\":true}" + }; + + orgUser.Id = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns(orgUser); + + sutProvider.GetDependency() + .GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin) + .Returns(new List { admin1, admin2 }); + + sutProvider.GetDependency() + .GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom) + .Returns(new List { customUser }); + + await sutProvider.Sut.PushAsync(userId, organizationId); + + await sutProvider.GetDependency() + .Received(1) + .PushAsync(Arg.Is>(pn => + pn.TargetId == duplicateUserId)); + } + + [Theory] + [BitAutoData] + public async Task PushAsync_OrganizationUserNotFound_ThrowsException( + SutProvider sutProvider, + Guid userId, + Guid organizationId) + { + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns((OrganizationUser)null); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.PushAsync(userId, organizationId)); + + Assert.Equal("Organization user not found", exception.Message); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushAsync(Arg.Any>()); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationOrganizationPolicyComplianceValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationOrganizationPolicyComplianceValidatorTests.cs new file mode 100644 index 0000000000..3376dea141 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationOrganizationPolicyComplianceValidatorTests.cs @@ -0,0 +1,544 @@ +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +[SutProviderCustomize] +public class AutomaticUserConfirmationOrganizationPolicyComplianceValidatorTests +{ + [Theory, BitAutoData] + public async Task IsOrganizationCompliantAsync_AllUsersCompliant_NoProviders_ReturnsValid( + Guid organizationId, + Guid userId, + SutProvider sutProvider) + { + // Arrange + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = userId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId); + + // Act + var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + } + + [Theory, BitAutoData] + public async Task IsOrganizationCompliantAsync_UserInAnotherOrg_ReturnsUserNotCompliantWithSingleOrganization( + Guid organizationId, + Guid userId, + SutProvider sutProvider) + { + // Arrange + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = userId, + Status = OrganizationUserStatusType.Confirmed + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), // Different org + UserId = userId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId); + + // Act + var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task IsOrganizationCompliantAsync_ProviderUsersExist_ReturnsProviderExistsInOrganization( + Guid organizationId, + Guid userId, + SutProvider sutProvider) + { + // Arrange + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = userId, + Status = OrganizationUserStatusType.Confirmed + }; + + var providerUser = new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = Guid.NewGuid(), + UserId = userId + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([providerUser]); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId); + + // Act + var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task IsOrganizationCompliantAsync_InvitedUsersExcluded_FromSingleOrgCheck( + Guid organizationId, + SutProvider sutProvider) + { + // Arrange - invited user has null UserId and Invited status + var invitedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = null, + Status = OrganizationUserStatusType.Invited, + Email = "invited@example.com" + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns([invitedUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId); + + // Act + var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + + // Invited users with null UserId should not trigger the single org query + await sutProvider.GetDependency() + .Received(1) + .GetManyByManyUsersAsync(Arg.Is>(ids => !ids.Any())); + } + + [Theory, BitAutoData] + public async Task IsOrganizationCompliantAsync_InvitedUserWithUserId_ExcludedFromSingleOrgCheck( + Guid organizationId, + Guid userId, + SutProvider sutProvider) + { + // Arrange - Invited status users are excluded regardless of UserId + var invitedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = userId, + Status = OrganizationUserStatusType.Invited + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns([invitedUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId); + + // Act + var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + + // Invited users should not be included in the single org compliance query + await sutProvider.GetDependency() + .Received(1) + .GetManyByManyUsersAsync(Arg.Is>(ids => !ids.Any())); + } + + [Theory, BitAutoData] + public async Task IsOrganizationCompliantAsync_UserInAnotherOrgWithInvitedStatus_ReturnsValid( + Guid organizationId, + Guid userId, + SutProvider sutProvider) + { + // Arrange + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = userId, + Status = OrganizationUserStatusType.Confirmed + }; + + // User has an Invited status in another org - should not count as non-compliant + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = userId, + Status = OrganizationUserStatusType.Invited + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId); + + // Act + var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + } + + [Theory, BitAutoData] + public async Task IsOrganizationCompliantAsync_SingleOrgViolationTakesPrecedence_OverProviderCheck( + Guid organizationId, + Guid userId, + SutProvider sutProvider) + { + // Arrange - user is in another org AND is a provider user + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = userId, + Status = OrganizationUserStatusType.Confirmed + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = userId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId); + + // Act + var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + + // Provider check should not be called since single org check failed first + await sutProvider.GetDependency() + .DidNotReceive() + .GetManyByManyUsersAsync(Arg.Any>()); + } + + [Theory, BitAutoData] + public async Task IsOrganizationCompliantAsync_MixedUsers_OnlyNonInvitedChecked( + Guid organizationId, + Guid confirmedUserId, + Guid acceptedUserId, + SutProvider sutProvider) + { + // Arrange + var invitedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = null, + Status = OrganizationUserStatusType.Invited, + Email = "invited@example.com" + }; + + var confirmedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = confirmedUserId, + Status = OrganizationUserStatusType.Confirmed + }; + + var acceptedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = acceptedUserId, + Status = OrganizationUserStatusType.Accepted + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns([invitedUser, confirmedUser, acceptedUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId); + + // Act + var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + + // Only confirmed and accepted users should be checked for single org compliance + await sutProvider.GetDependency() + .Received(1) + .GetManyByManyUsersAsync(Arg.Is>(ids => + ids.Count() == 2 && + ids.Contains(confirmedUserId) && + ids.Contains(acceptedUserId))); + } + + [Theory, BitAutoData] + public async Task IsOrganizationCompliantAsync_NoOrganizationUsers_ReturnsValid( + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId); + + // Act + var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + } + + [Theory, BitAutoData] + public async Task IsOrganizationCompliantAsync_UserInSameOrgOnly_ReturnsValid( + Guid organizationId, + Guid userId, + SutProvider sutProvider) + { + // Arrange + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = userId, + Status = OrganizationUserStatusType.Confirmed + }; + + // User exists in the same org only (the GetManyByManyUsersAsync returns same-org entry) + var sameOrgUser = new OrganizationUser + { + Id = orgUser.Id, + OrganizationId = organizationId, + UserId = userId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([sameOrgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId); + + // Act + var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + } + + [Theory, BitAutoData] + public async Task IsOrganizationCompliantAsync_ProviderCheckIncludesAllUsersWithUserIds( + Guid organizationId, + Guid userId1, + Guid userId2, + SutProvider sutProvider) + { + // Arrange - provider check includes users regardless of Invited status (only excludes null UserId) + var confirmedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = userId1, + Status = OrganizationUserStatusType.Confirmed + }; + + var invitedUserWithNullId = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = null, + Status = OrganizationUserStatusType.Invited, + Email = "invited@example.com" + }; + + var acceptedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = userId2, + Status = OrganizationUserStatusType.Accepted + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns([confirmedUser, invitedUserWithNullId, acceptedUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId); + + // Act + var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + + // Provider check should include all users with non-null UserIds (confirmed + accepted) + await sutProvider.GetDependency() + .Received(1) + .GetManyByManyUsersAsync(Arg.Is>(ids => + ids.Count() == 2 && + ids.Contains(userId1) && + ids.Contains(userId2))); + } + + [Theory, BitAutoData] + public async Task IsOrganizationCompliantAsync_RevokedUserInAnotherOrg_ReturnsUserNotCompliant( + Guid organizationId, + Guid userId, + SutProvider sutProvider) + { + // Arrange + var revokedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = userId, + Status = OrganizationUserStatusType.Revoked + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = userId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns([revokedUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId); + + // Act + var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs index e2c9de4d6f..45d3a0b6ee 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs @@ -1,19 +1,14 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Entities; -using Bit.Core.Enums; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Repositories; using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; @@ -34,35 +29,14 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_UsersNotCompliantWithSingleOrg_ReturnsError( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - Guid nonCompliantUserId, SutProvider sutProvider) { // Arrange - var orgUser = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - OrganizationId = policyUpdate.OrganizationId, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Confirmed, - UserId = nonCompliantUserId, - Email = "user@example.com" - }; + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(policyUpdate.OrganizationId); - var otherOrgUser = new OrganizationUser - { - Id = Guid.NewGuid(), - OrganizationId = Guid.NewGuid(), - UserId = nonCompliantUserId, - Status = OrganizationUserStatusType.Confirmed - }; - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([orgUser]); - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns([otherOrgUser]); + sutProvider.GetDependency() + .IsOrganizationCompliantAsync(Arg.Any()) + .Returns(Invalid(request, new UserNotCompliantWithSingleOrganization())); // Act var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); @@ -71,85 +45,17 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); } - [Theory, BitAutoData] - public async Task ValidateAsync_EnablingPolicy_UserWithInvitedStatusInOtherOrg_ValidationPasses( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - Guid userId, - SutProvider sutProvider) - { - // Arrange - var orgUser = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - OrganizationId = policyUpdate.OrganizationId, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Confirmed, - UserId = userId, - }; - - var otherOrgUser = new OrganizationUser - { - Id = Guid.NewGuid(), - OrganizationId = Guid.NewGuid(), - UserId = null, // invited users do not have a user id - Status = OrganizationUserStatusType.Invited, - Email = orgUser.Email - }; - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([orgUser]); - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns([otherOrgUser]); - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns([]); - - // Act - var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); - - // Assert - Assert.True(string.IsNullOrEmpty(result)); - } - [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_ProviderUsersExist_ReturnsError( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - Guid userId, SutProvider sutProvider) { // Arrange - var orgUser = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - OrganizationId = policyUpdate.OrganizationId, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Confirmed, - UserId = userId - }; + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(policyUpdate.OrganizationId); - var providerUser = new ProviderUser - { - Id = Guid.NewGuid(), - ProviderId = Guid.NewGuid(), - UserId = userId, - Status = ProviderUserStatusType.Confirmed - }; - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([orgUser]); - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns([]); - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns([providerUser]); + sutProvider.GetDependency() + .IsOrganizationCompliantAsync(Arg.Any()) + .Returns(Invalid(request, new ProviderExistsInOrganization())); // Act var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); @@ -158,33 +64,17 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Assert.Contains("Provider user type", result, StringComparison.OrdinalIgnoreCase); } - [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_AllValidationsPassed_ReturnsEmptyString( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, SutProvider sutProvider) { // Arrange - var orgUser = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - OrganizationId = policyUpdate.OrganizationId, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Confirmed, - UserId = Guid.NewGuid() - }; + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(policyUpdate.OrganizationId); - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([orgUser]); - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns([]); - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns([]); + sutProvider.GetDependency() + .IsOrganizationCompliantAsync(Arg.Any()) + .Returns(Valid(request)); // Act var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); @@ -208,9 +98,9 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests // Assert Assert.True(string.IsNullOrEmpty(result)); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .GetManyDetailsByOrganizationAsync(Arg.Any()); + .IsOrganizationCompliantAsync(Arg.Any()); } [Theory, BitAutoData] @@ -227,212 +117,31 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests // Assert Assert.True(string.IsNullOrEmpty(result)); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .GetManyDetailsByOrganizationAsync(Arg.Any()); + .IsOrganizationCompliantAsync(Arg.Any()); } [Theory, BitAutoData] - public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - Guid nonCompliantOwnerId, - SutProvider sutProvider) - { - // Arrange - var ownerUser = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - OrganizationId = policyUpdate.OrganizationId, - Type = OrganizationUserType.Owner, - Status = OrganizationUserStatusType.Confirmed, - UserId = nonCompliantOwnerId, - }; - - var otherOrgUser = new OrganizationUser - { - Id = Guid.NewGuid(), - OrganizationId = Guid.NewGuid(), - UserId = nonCompliantOwnerId, - Status = OrganizationUserStatusType.Confirmed - }; - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([ownerUser]); - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns([otherOrgUser]); - - // Act - var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); - - // Assert - Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); - } - - [Theory, BitAutoData] - public async Task ValidateAsync_EnablingPolicy_InvitedUsersExcluded_FromComplianceCheck( + public async Task ValidateAsync_EnablingPolicy_PassesCorrectOrganizationId( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, SutProvider sutProvider) { // Arrange - var invitedUser = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - OrganizationId = policyUpdate.OrganizationId, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Invited, - UserId = null, - Email = "invited@example.com" - }; + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(policyUpdate.OrganizationId); - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([invitedUser]); - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns([]); + sutProvider.GetDependency() + .IsOrganizationCompliantAsync(Arg.Any()) + .Returns(Valid(request)); // Act - var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + await sutProvider.Sut.ValidateAsync(policyUpdate, null); // Assert - Assert.True(string.IsNullOrEmpty(result)); - } - - [Theory, BitAutoData] - public async Task ValidateAsync_EnablingPolicy_MixedUsersWithNullUserId_HandlesCorrectly( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - Guid confirmedUserId, - SutProvider sutProvider) - { - // Arrange - var invitedUser = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - OrganizationId = policyUpdate.OrganizationId, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Invited, - UserId = null, - Email = "invited@example.com" - }; - - var confirmedUser = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - OrganizationId = policyUpdate.OrganizationId, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Confirmed, - UserId = confirmedUserId, - Email = "confirmed@example.com" - }; - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([invitedUser, confirmedUser]); - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns([]); - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns([]); - - // Act - var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); - - // Assert - Assert.True(string.IsNullOrEmpty(result)); - - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .GetManyByManyUsersAsync(Arg.Is>(ids => ids.Count() == 1 && ids.First() == confirmedUserId)); - } - - [Theory, BitAutoData] - public async Task ValidateAsync_EnablingPolicy_RevokedUsersIncluded_InComplianceCheck( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - SutProvider sutProvider) - { - // Arrange - var revokedUser = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - OrganizationId = policyUpdate.OrganizationId, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Revoked, - UserId = Guid.NewGuid(), - }; - - var additionalOrgUser = new OrganizationUser - { - Id = Guid.NewGuid(), - OrganizationId = Guid.NewGuid(), - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Revoked, - UserId = revokedUser.UserId, - }; - - var orgUserRepository = sutProvider.GetDependency(); - - orgUserRepository - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([revokedUser]); - - orgUserRepository.GetManyByManyUsersAsync(Arg.Any>()) - .Returns([additionalOrgUser]); - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns([]); - - // Act - var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); - - // Assert - Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); - } - - [Theory, BitAutoData] - public async Task ValidateAsync_EnablingPolicy_AcceptedUsersIncluded_InComplianceCheck( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - Guid nonCompliantUserId, - SutProvider sutProvider) - { - // Arrange - var acceptedUser = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - OrganizationId = policyUpdate.OrganizationId, - Type = OrganizationUserType.User, - Status = OrganizationUserStatusType.Accepted, - UserId = nonCompliantUserId, - }; - - var otherOrgUser = new OrganizationUser - { - Id = Guid.NewGuid(), - OrganizationId = Guid.NewGuid(), - UserId = nonCompliantUserId, - Status = OrganizationUserStatusType.Confirmed - }; - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([acceptedUser]); - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns([otherOrgUser]); - - // Act - var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); - - // Assert - Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); + .IsOrganizationCompliantAsync(Arg.Is( + r => r.OrganizationId == policyUpdate.OrganizationId)); } [Theory, BitAutoData] @@ -442,10 +151,11 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests { // Arrange var savePolicyModel = new SavePolicyModel(policyUpdate); + var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(policyUpdate.OrganizationId); - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([]); + sutProvider.GetDependency() + .IsOrganizationCompliantAsync(Arg.Any()) + .Returns(Valid(request)); // Act var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidatorTests.cs index e317a5886e..2b277c6ae6 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidatorTests.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; -using Bit.Core.Services; using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -20,10 +19,6 @@ public class BlockClaimedDomainAccountCreationPolicyValidatorTests SutProvider sutProvider) { // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency() .HasVerifiedDomainsAsync(policyUpdate.OrganizationId) .Returns(false); @@ -41,10 +36,6 @@ public class BlockClaimedDomainAccountCreationPolicyValidatorTests SutProvider sutProvider) { // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency() .HasVerifiedDomainsAsync(policyUpdate.OrganizationId) .Returns(true); @@ -61,11 +52,6 @@ public class BlockClaimedDomainAccountCreationPolicyValidatorTests [PolicyUpdate(PolicyType.BlockClaimedDomainAccountCreation, false)] PolicyUpdate policyUpdate, SutProvider sutProvider) { - // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - // Act var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); @@ -82,10 +68,6 @@ public class BlockClaimedDomainAccountCreationPolicyValidatorTests SutProvider sutProvider) { // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency() .HasVerifiedDomainsAsync(policyUpdate.OrganizationId) .Returns(false); @@ -105,10 +87,6 @@ public class BlockClaimedDomainAccountCreationPolicyValidatorTests SutProvider sutProvider) { // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency() .HasVerifiedDomainsAsync(policyUpdate.OrganizationId) .Returns(true); @@ -128,10 +106,6 @@ public class BlockClaimedDomainAccountCreationPolicyValidatorTests SutProvider sutProvider) { // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); // Act @@ -144,31 +118,11 @@ public class BlockClaimedDomainAccountCreationPolicyValidatorTests .HasVerifiedDomainsAsync(Arg.Any()); } - [Theory, BitAutoData] - public async Task ValidateAsync_FeatureFlagDisabled_ReturnsError( - [PolicyUpdate(PolicyType.BlockClaimedDomainAccountCreation, true)] PolicyUpdate policyUpdate, - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(false); - - // Act - var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); - - // Assert - Assert.Equal("This feature is not enabled", result); - await sutProvider.GetDependency() - .DidNotReceive() - .HasVerifiedDomainsAsync(Arg.Any()); - } - [Fact] public void Type_ReturnsBlockClaimedDomainAccountCreation() { // Arrange - var validator = new BlockClaimedDomainAccountCreationPolicyValidator(null, null); + var validator = new BlockClaimedDomainAccountCreationPolicyValidator(null); // Act & Assert Assert.Equal(PolicyType.BlockClaimedDomainAccountCreation, validator.Type); @@ -178,7 +132,7 @@ public class BlockClaimedDomainAccountCreationPolicyValidatorTests public void RequiredPolicies_ReturnsEmpty() { // Arrange - var validator = new BlockClaimedDomainAccountCreationPolicyValidator(null, null); + var validator = new BlockClaimedDomainAccountCreationPolicyValidator(null); // Act var requiredPolicies = validator.RequiredPolicies.ToList(); diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index 29193bacbc..5631fd7f54 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -106,9 +106,14 @@ public class RegisterUserCommandTests { // Arrange user.Id = Guid.NewGuid(); + user.Email = $"test+{Guid.NewGuid()}@example.com"; organization.Id = Guid.NewGuid(); organization.Name = "Test Organization"; + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), organization.Id) + .Returns(false); + sutProvider.GetDependency() .CreateUserAsync(user) .Returns(IdentityResult.Success); @@ -134,6 +139,12 @@ public class RegisterUserCommandTests SutProvider sutProvider) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), organization.Id) + .Returns(false); + var expectedError = new IdentityError(); sutProvider.GetDependency() .CreateUserAsync(user) @@ -161,9 +172,14 @@ public class RegisterUserCommandTests SutProvider sutProvider) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; organization.PlanType = planType; organization.Name = "Enterprise Org"; + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), organization.Id) + .Returns(false); + sutProvider.GetDependency() .CreateUserAsync(user) .Returns(IdentityResult.Success); @@ -192,6 +208,12 @@ public class RegisterUserCommandTests SutProvider sutProvider) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), organization.Id) + .Returns(false); + sutProvider.GetDependency() .CreateUserAsync(user) .Returns(IdentityResult.Success); @@ -220,8 +242,13 @@ public class RegisterUserCommandTests SutProvider sutProvider, User user, string masterPasswordHash) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; user.ReferenceData = null; + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .CreateUserAsync(user, masterPasswordHash) .Returns(IdentityResult.Success); @@ -247,6 +274,12 @@ public class RegisterUserCommandTests [Policy(PolicyType.TwoFactorAuthentication, true)] PolicyStatus policy) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .DisableUserRegistration.Returns(false); @@ -350,6 +383,12 @@ public class RegisterUserCommandTests SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid? orgUserId) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .DisableUserRegistration.Returns(true); @@ -388,6 +427,12 @@ public class RegisterUserCommandTests SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid? orgUserId) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .DisableUserRegistration.Returns(false); @@ -457,10 +502,6 @@ public class RegisterUserCommandTests .GetByIdAsync(orgUserId) .Returns(orgUser); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - // Mock the new overload that excludes the organization - it should return true (domain IS blocked by another org) sutProvider.GetDependency() .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com", orgUser.OrganizationId) @@ -504,10 +545,6 @@ public class RegisterUserCommandTests .GetByIdAsync(orgUserId) .Returns(orgUser); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - // Mock the new overload - it should return false (domain is NOT blocked by OTHER orgs) sutProvider.GetDependency() .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("company-domain.com", orgUser.OrganizationId) @@ -541,6 +578,10 @@ public class RegisterUserCommandTests orgUser.Email = user.Email; orgUser.Id = orgUserId; + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) + .Returns(false); + var orgInviteTokenable = new OrgUserInviteTokenable(orgUser); sutProvider.GetDependency>() @@ -644,6 +685,12 @@ public class RegisterUserCommandTests public async Task RegisterUserViaEmailVerificationToken_DisabledOpenRegistration_ThrowsBadRequestException(SutProvider sutProvider, User user, string masterPasswordHash, string emailVerificationToken) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .DisableUserRegistration = true; @@ -721,6 +768,12 @@ public class RegisterUserCommandTests string masterPasswordHash, string orgSponsoredFreeFamilyPlanInviteToken) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .DisableUserRegistration = true; @@ -811,6 +864,12 @@ public class RegisterUserCommandTests string masterPasswordHash, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .DisableUserRegistration = true; @@ -931,6 +990,8 @@ public class RegisterUserCommandTests User user, string masterPasswordHash, Guid providerUserId) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + // Start with plaintext var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow); var decryptedProviderInviteToken = $"ProviderUserInvite {providerUserId} {user.Email} {nowMillis}"; @@ -950,6 +1011,10 @@ public class RegisterUserCommandTests .CreateProtector("ProviderServiceDataProtector") .Returns(mockDataProtector); + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .DisableUserRegistration = true; @@ -975,10 +1040,6 @@ public class RegisterUserCommandTests // Arrange user.Email = "user@blocked-domain.com"; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency() .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com") .Returns(true); @@ -1002,10 +1063,6 @@ public class RegisterUserCommandTests // Arrange user.Email = "user@allowed-domain.com"; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency() .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("allowed-domain.com") .Returns(false); @@ -1038,9 +1095,14 @@ public class RegisterUserCommandTests SutProvider sutProvider) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; organization.PlanType = planType; organization.Name = "Family Org"; + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), organization.Id) + .Returns(false); + sutProvider.GetDependency() .CreateUserAsync(user) .Returns(IdentityResult.Success); @@ -1071,10 +1133,6 @@ public class RegisterUserCommandTests // Arrange user.Email = "user@blocked-domain.com"; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency() .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com") .Returns(true); @@ -1102,10 +1160,6 @@ public class RegisterUserCommandTests // Arrange user.Email = "user@blocked-domain.com"; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency() .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com") .Returns(true); @@ -1131,10 +1185,6 @@ public class RegisterUserCommandTests emergencyAccess.Email = user.Email; emergencyAccess.Id = acceptEmergencyAccessId; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency() .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com") .Returns(true); @@ -1183,10 +1233,6 @@ public class RegisterUserCommandTests sutProvider.GetDependency() .OrganizationInviteExpirationHours.Returns(120); // 5 days - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency() .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com") .Returns(true); @@ -1213,10 +1259,6 @@ public class RegisterUserCommandTests // Arrange user.Email = "invalid-email-format"; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - // Act & Assert var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUser(user)); @@ -1232,10 +1274,6 @@ public class RegisterUserCommandTests // Arrange user.Email = "invalid-email-format"; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency>() .TryUnprotect(emailVerificationToken, out Arg.Any()) .Returns(callInfo => @@ -1261,9 +1299,14 @@ public class RegisterUserCommandTests SutProvider sutProvider) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; user.ReferenceData = null; orgUser.Email = user.Email; + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .CreateUserAsync(user, masterPasswordHash) .Returns(IdentityResult.Success); @@ -1310,11 +1353,16 @@ public class RegisterUserCommandTests SutProvider sutProvider) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; Organization organization = new Organization { Name = null }; + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .CreateUserAsync(user) .Returns(IdentityResult.Success); @@ -1348,10 +1396,15 @@ public class RegisterUserCommandTests SutProvider sutProvider) { // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; user.ReferenceData = null; orgUser.Email = user.Email; organization.PlanType = PlanType.EnterpriseAnnually; + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .CreateUserAsync(user, masterPasswordHash) .Returns(IdentityResult.Success); @@ -1406,10 +1459,6 @@ public class RegisterUserCommandTests // Arrange user.Email = "user@blocked-domain.com"; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency() .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com", organization.Id) .Returns(true); @@ -1429,10 +1478,6 @@ public class RegisterUserCommandTests // Arrange user.Email = "user@company-domain.com"; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - // Domain is claimed by THIS organization, so it should be allowed sutProvider.GetDependency() .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("company-domain.com", organization.Id) @@ -1461,10 +1506,6 @@ public class RegisterUserCommandTests // Arrange user.Email = "user@unclaimed-domain.com"; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency() .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("unclaimed-domain.com", organization.Id) .Returns(false); // Domain is not claimed by any org diff --git a/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs index 91e8351d2c..c7bb72ccbb 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs @@ -59,9 +59,11 @@ public class SendVerificationEmailForRegistrationCommandTests [Theory] [BitAutoData] public async Task SendVerificationEmailForRegistrationCommand_WhenFromMarketingIsPremium_SendsEmailWithMarketingParameterAndReturnsNull(SutProvider sutProvider, - string email, string name, bool receiveMarketingEmails) + string name, bool receiveMarketingEmails) { // Arrange + var email = $"test+{Guid.NewGuid()}@example.com"; + sutProvider.GetDependency() .GetByEmailAsync(email) .ReturnsNull(); @@ -167,9 +169,15 @@ public class SendVerificationEmailForRegistrationCommandTests [Theory] [BitAutoData] public async Task SendVerificationEmailForRegistrationCommand_WhenOpenRegistrationDisabled_ThrowsBadRequestException(SutProvider sutProvider, - string email, string name, bool receiveMarketingEmails) + string name, bool receiveMarketingEmails) { // Arrange + var email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + sutProvider.GetDependency() .DisableUserRegistration = true; @@ -235,10 +243,6 @@ public class SendVerificationEmailForRegistrationCommandTests sutProvider.GetDependency() .DisableUserRegistration = false; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency() .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blockedcompany.com") .Returns(true); @@ -266,10 +270,6 @@ public class SendVerificationEmailForRegistrationCommandTests sutProvider.GetDependency() .DisableUserRegistration = false; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - sutProvider.GetDependency() .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("allowedcompany.com") .Returns(false); @@ -298,10 +298,6 @@ public class SendVerificationEmailForRegistrationCommandTests sutProvider.GetDependency() .DisableUserRegistration = false; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) - .Returns(true); - // Act & Assert var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null)); diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/CompleteTwoFactorWebAuthnRegistrationCommandTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/CompleteTwoFactorWebAuthnRegistrationCommandTests.cs new file mode 100644 index 0000000000..fec6d91983 --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/CompleteTwoFactorWebAuthnRegistrationCommandTests.cs @@ -0,0 +1,157 @@ +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations; +using Bit.Core.Billing.Premium.Queries; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Fido2NetLib; +using Fido2NetLib.Objects; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.TwoFactorAuth; + +[SutProviderCustomize] +public class CompleteTwoFactorWebAuthnRegistrationCommandTests +{ + /// + /// The "Start" command will have set the in-process credential registration request to "pending" status. + /// The purpose of Complete is to consume and enshrine this pending credential. + /// + private static void SetupWebAuthnProviderWithPending(User user, int credentialCount) + { + var providers = new Dictionary(); + var metadata = new Dictionary(); + + // Add existing credentials + for (var i = 1; i <= credentialCount; i++) + { + metadata[$"Key{i}"] = new TwoFactorProvider.WebAuthnData + { + Name = $"Key {i}", + Descriptor = new PublicKeyCredentialDescriptor([(byte)i]), + PublicKey = [(byte)i], + UserHandle = [(byte)i], + SignatureCounter = 0, + CredType = "public-key", + RegDate = DateTime.UtcNow, + AaGuid = Guid.NewGuid() + }; + } + + // Add pending registration + var pendingOptions = new CredentialCreateOptions + { + Challenge = [1, 2, 3], + Rp = new PublicKeyCredentialRpEntity("example.com", "example.com", ""), + User = new Fido2User + { + Id = user.Id.ToByteArray(), + Name = user.Email ?? "test@example.com", + DisplayName = user.Name ?? "Test User" + }, + PubKeyCredParams = [] + }; + metadata["pending"] = pendingOptions.ToJson(); + + providers[TwoFactorProviderType.WebAuthn] = new TwoFactorProvider { Enabled = true, MetaData = metadata }; + + user.SetTwoFactorProviders(providers); + } + + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task CompleteWebAuthRegistrationAsync_BelowLimit_Succeeds(bool hasPremium, + SutProvider sutProvider, User user, + AuthenticatorAttestationRawResponse deviceResponse) + { + // Arrange - User should have 1 available credential, determined by maximum allowed for tested Premium status. + var maximumAllowedCredentialsGlobalSetting = new Core.Settings.GlobalSettings.WebAuthnSettings + { + PremiumMaximumAllowedCredentials = 10, + NonPremiumMaximumAllowedCredentials = 5 + }; + + sutProvider.GetDependency().WebAuthn = maximumAllowedCredentialsGlobalSetting; + + user.Premium = hasPremium; + user.Id = Guid.NewGuid(); + user.Email = "test@example.com"; + + sutProvider.GetDependency().HasPremiumAccessAsync(user.Id).Returns(hasPremium); + + SetupWebAuthnProviderWithPending(user, + credentialCount: hasPremium + ? maximumAllowedCredentialsGlobalSetting.PremiumMaximumAllowedCredentials - 1 + : maximumAllowedCredentialsGlobalSetting.NonPremiumMaximumAllowedCredentials - 1); + + var mockFido2 = sutProvider.GetDependency(); + mockFido2.MakeNewCredentialAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new Fido2.CredentialMakeResult("ok", "", + new AttestationVerificationSuccess + { + Aaguid = Guid.NewGuid(), + Counter = 0, + CredentialId = [1, 2, 3], + CredType = "public-key", + PublicKey = [4, 5, 6], + Status = "ok", + User = new Fido2User + { + Id = user.Id.ToByteArray(), + Name = user.Email ?? "test@example.com", + DisplayName = user.Name ?? "Test User" + } + })); + + // Act + var result = + await sutProvider.Sut.CompleteTwoFactorWebAuthnRegistrationAsync(user, 5, "NewKey", deviceResponse); + + // Assert + // Note that, contrary to the "Start" command, "Complete" does not suppress logging for the update providers invocation. + Assert.True(result); + await sutProvider.GetDependency().Received(1) + .UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); + } + + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task CompleteWebAuthRegistrationAsync_ExceedsLimit_ThrowsBadRequestException(bool hasPremium, + SutProvider sutProvider, User user, + AuthenticatorAttestationRawResponse deviceResponse) + { + // Arrange - time-of-check/time-of-use scenario: user now has 10 credentials (at limit) + var maximumAllowedCredentialsGlobalSetting = new Core.Settings.GlobalSettings.WebAuthnSettings + { + PremiumMaximumAllowedCredentials = 10, + NonPremiumMaximumAllowedCredentials = 5 + }; + + sutProvider.GetDependency().WebAuthn = maximumAllowedCredentialsGlobalSetting; + + user.Premium = hasPremium; + sutProvider.GetDependency().HasPremiumAccessAsync(user.Id).Returns(hasPremium); + + + SetupWebAuthnProviderWithPending(user, + credentialCount: hasPremium + ? maximumAllowedCredentialsGlobalSetting.PremiumMaximumAllowedCredentials + : maximumAllowedCredentialsGlobalSetting.NonPremiumMaximumAllowedCredentials); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CompleteTwoFactorWebAuthnRegistrationAsync(user, 11, "NewKey", deviceResponse)); + + Assert.Equal("Maximum allowed WebAuthn credential count exceeded.", exception.Message); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/DeleteTwoFactorWebAuthnCredentialCommandTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/DeleteTwoFactorWebAuthnCredentialCommandTests.cs new file mode 100644 index 0000000000..b1dfe46573 --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/DeleteTwoFactorWebAuthnCredentialCommandTests.cs @@ -0,0 +1,138 @@ +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations; +using Bit.Core.Entities; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Fido2NetLib.Objects; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.TwoFactorAuth; + +[SutProviderCustomize] +public class DeleteTwoFactorWebAuthnCredentialCommandTests +{ + private static void SetupWebAuthnProvider(User user, int credentialCount) + { + var providers = new Dictionary(); + var metadata = new Dictionary(); + + // Add credentials as Key1, Key2, Key3, etc. + for (var i = 1; i <= credentialCount; i++) + { + metadata[$"Key{i}"] = new TwoFactorProvider.WebAuthnData + { + Name = $"Key {i}", + Descriptor = new PublicKeyCredentialDescriptor([(byte)i]), + PublicKey = [(byte)i], + UserHandle = [(byte)i], + SignatureCounter = 0, + CredType = "public-key", + RegDate = DateTime.UtcNow, + AaGuid = Guid.NewGuid() + }; + } + + providers[TwoFactorProviderType.WebAuthn] = new TwoFactorProvider { Enabled = true, MetaData = metadata }; + + user.SetTwoFactorProviders(providers); + } + + /// + /// When the user has multiple WebAuthn credentials and requests deletion of an existing key, + /// the command should remove it, persist via UserService, and return true. + /// + [Theory, BitAutoData] + public async Task DeleteAsync_KeyExistsWithMultipleKeys_RemovesKeyAndReturnsTrue( + SutProvider sutProvider, User user) + { + // Arrange + SetupWebAuthnProvider(user, 3); + var keyIdToDelete = 2; + + // Act + var result = await sutProvider.Sut.DeleteTwoFactorWebAuthnCredentialAsync(user, keyIdToDelete); + + // Assert + Assert.True(result); + + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); + Assert.NotNull(provider?.MetaData); + Assert.False(provider.MetaData.ContainsKey($"Key{keyIdToDelete}")); + Assert.Equal(2, provider.MetaData.Count); + + await sutProvider.GetDependency().Received(1) + .UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn); + } + + /// + /// When the requested key does not exist, the command should return false + /// and not call UserService. + /// + [Theory, BitAutoData] + public async Task DeleteAsync_KeyDoesNotExist_ReturnsFalse( + SutProvider sutProvider, User user) + { + // Arrange + SetupWebAuthnProvider(user, 2); + var nonExistentKeyId = 99; + + // Act + var result = await sutProvider.Sut.DeleteTwoFactorWebAuthnCredentialAsync(user, nonExistentKeyId); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency().DidNotReceive() + .UpdateTwoFactorProviderAsync(Arg.Any(), Arg.Any()); + } + + /// + /// Users must retain at least one WebAuthn credential. When only one key remains, + /// deletion should be rejected to prevent lockout. + /// + [Theory, BitAutoData] + public async Task DeleteAsync_OnlyOneKeyRemaining_ReturnsFalse( + SutProvider sutProvider, User user) + { + // Arrange + SetupWebAuthnProvider(user, 1); + var keyIdToDelete = 1; + + // Act + var result = await sutProvider.Sut.DeleteTwoFactorWebAuthnCredentialAsync(user, keyIdToDelete); + + // Assert + Assert.False(result); + + // Key should still exist + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); + Assert.NotNull(provider?.MetaData); + Assert.True(provider.MetaData.ContainsKey($"Key{keyIdToDelete}")); + + await sutProvider.GetDependency().DidNotReceive() + .UpdateTwoFactorProviderAsync(Arg.Any(), Arg.Any()); + } + + /// + /// When the user has no two-factor providers configured, deletion should return false. + /// + [Theory, BitAutoData] + public async Task DeleteAsync_NoProviders_ReturnsFalse( + SutProvider sutProvider, User user) + { + // Arrange - user with no providers (clear any AutoFixture-generated ones) + user.SetTwoFactorProviders(null); + + // Act + var result = await sutProvider.Sut.DeleteTwoFactorWebAuthnCredentialAsync(user, 1); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency().DidNotReceive() + .UpdateTwoFactorProviderAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/StartTwoFactorWebAuthnRegistrationCommandTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/StartTwoFactorWebAuthnRegistrationCommandTests.cs new file mode 100644 index 0000000000..455b9d0fdf --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/StartTwoFactorWebAuthnRegistrationCommandTests.cs @@ -0,0 +1,147 @@ +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations; +using Bit.Core.Billing.Premium.Queries; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Fido2NetLib; +using Fido2NetLib.Objects; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.TwoFactorAuth; + +[SutProviderCustomize] +public class StartTwoFactorWebAuthnRegistrationCommandTests +{ + private static void SetupWebAuthnProvider(User user, int credentialCount) + { + var providers = new Dictionary(); + var metadata = new Dictionary(); + + // Add credentials as Key1, Key2, Key3, etc. + for (var i = 1; i <= credentialCount; i++) + { + metadata[$"Key{i}"] = new TwoFactorProvider.WebAuthnData + { + Name = $"Key {i}", + Descriptor = new PublicKeyCredentialDescriptor([(byte)i]), + PublicKey = [(byte)i], + UserHandle = [(byte)i], + SignatureCounter = 0, + CredType = "public-key", + RegDate = DateTime.UtcNow, + AaGuid = Guid.NewGuid() + }; + } + + providers[TwoFactorProviderType.WebAuthn] = new TwoFactorProvider { Enabled = true, MetaData = metadata }; + + user.SetTwoFactorProviders(providers); + } + + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task StartWebAuthnRegistrationAsync_BelowLimit_Succeeds( + bool hasPremium, SutProvider sutProvider, User user) + { + // Arrange - User should have 1 available credential, determined by maximum allowed for tested Premium status. + var maximumAllowedCredentialsGlobalSetting = new Core.Settings.GlobalSettings.WebAuthnSettings + { + PremiumMaximumAllowedCredentials = 10, + NonPremiumMaximumAllowedCredentials = 5 + }; + + sutProvider.GetDependency().WebAuthn = maximumAllowedCredentialsGlobalSetting; + + user.Premium = hasPremium; + user.Id = Guid.NewGuid(); + user.Email = "test@example.com"; + + sutProvider.GetDependency().HasPremiumAccessAsync(user.Id).Returns(hasPremium); + + SetupWebAuthnProvider(user, + credentialCount: hasPremium + ? maximumAllowedCredentialsGlobalSetting.PremiumMaximumAllowedCredentials - 1 + : maximumAllowedCredentialsGlobalSetting.NonPremiumMaximumAllowedCredentials - 1); + + var mockFido2 = sutProvider.GetDependency(); + mockFido2.RequestNewCredential( + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new CredentialCreateOptions + { + Challenge = [1, 2, 3], + Rp = new PublicKeyCredentialRpEntity("example.com", "example.com", ""), + User = new Fido2User { Id = user.Id.ToByteArray(), Name = user.Email, DisplayName = user.Name }, + PubKeyCredParams = [] + }); + + // Act + var result = await sutProvider.Sut.StartTwoFactorWebAuthnRegistrationAsync(user); + + // Assert + Assert.NotNull(result); + await sutProvider.GetDependency().Received(1) + .UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn, false); + } + + /// + /// "Start" provides the first half of a two-part process for registering a new WebAuthn 2FA credential. + /// To provide the best (most aggressive) UX possible, "Start" performs boundary validation of the ability to engage + /// in this flow based on current number of configured credentials. If the user is out of available credential slots, + /// Start should throw a BadRequestException for the client to handle. + /// + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task StartWebAuthnRegistrationAsync_ExceedsLimit_ThrowsBadRequestException( + bool hasPremium, SutProvider sutProvider, User user) + { + // Arrange - User should have 1 available credential, determined by maximum allowed for tested Premium status. + var maximumAllowedCredentialsGlobalSetting = new Core.Settings.GlobalSettings.WebAuthnSettings + { + PremiumMaximumAllowedCredentials = 10, + NonPremiumMaximumAllowedCredentials = 5 + }; + + sutProvider.GetDependency().WebAuthn = maximumAllowedCredentialsGlobalSetting; + + user.Premium = hasPremium; + user.Id = Guid.NewGuid(); + user.Email = "test@example.com"; + + sutProvider.GetDependency().HasPremiumAccessAsync(user.Id).Returns(hasPremium); + + SetupWebAuthnProvider(user, + credentialCount: hasPremium + ? maximumAllowedCredentialsGlobalSetting.PremiumMaximumAllowedCredentials + : maximumAllowedCredentialsGlobalSetting.NonPremiumMaximumAllowedCredentials); + + var mockFido2 = sutProvider.GetDependency(); + mockFido2.RequestNewCredential( + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new CredentialCreateOptions + { + Challenge = [1, 2, 3], + Rp = new PublicKeyCredentialRpEntity("example.com", "example.com", ""), + User = new Fido2User { Id = user.Id.ToByteArray(), Name = user.Email, DisplayName = user.Name }, + PubKeyCredParams = [] + }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.StartTwoFactorWebAuthnRegistrationAsync(user)); + Assert.Equal("Maximum allowed WebAuthn credential count exceeded.", exception.Message); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs index 3a98fb44fb..ed0560eeac 100644 --- a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs +++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs @@ -1,6 +1,6 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; -using Bit.Core.Auth.UserFeatures.TwoFactorAuth; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations; using Bit.Core.Billing.Premium.Queries; using Bit.Core.Entities; using Bit.Core.Exceptions; diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index da287dc02b..0e38850111 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -812,4 +812,255 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests await _userService.Received(1).SaveUserAsync(user); } + [Theory, BitAutoData] + public async Task Run_UserWithCanceledSubscription_AllowsResubscribe( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = true; // User still has Premium flag set + user.GatewayCustomerId = "existing_customer_123"; + user.GatewaySubscriptionId = "sub_canceled_123"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var existingCanceledSubscription = Substitute.For(); + existingCanceledSubscription.Id = "sub_canceled_123"; + existingCanceledSubscription.Status = "canceled"; // Terminal status + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "existing_customer_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var newSubscription = Substitute.For(); + newSubscription.Id = "sub_new_123"; + newSubscription.Status = "active"; + newSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId).Returns(existingCanceledSubscription); + _hasPaymentMethodQuery.Run(Arg.Any()).Returns(true); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); // Should succeed, not return "Already a premium user" + Assert.True(user.Premium); + Assert.Equal(newSubscription.Id, user.GatewaySubscriptionId); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); + await _userService.Received(1).SaveUserAsync(user); + } + + [Theory, BitAutoData] + public async Task Run_UserWithIncompleteExpiredSubscription_AllowsResubscribe( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = true; // User still has Premium flag set + user.GatewayCustomerId = "existing_customer_123"; + user.GatewaySubscriptionId = "sub_incomplete_expired_123"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var existingExpiredSubscription = Substitute.For(); + existingExpiredSubscription.Id = "sub_incomplete_expired_123"; + existingExpiredSubscription.Status = "incomplete_expired"; // Terminal status + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "existing_customer_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var newSubscription = Substitute.For(); + newSubscription.Id = "sub_new_123"; + newSubscription.Status = "active"; + newSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId).Returns(existingExpiredSubscription); + _hasPaymentMethodQuery.Run(Arg.Any()).Returns(true); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); // Should succeed, not return "Already a premium user" + Assert.True(user.Premium); + Assert.Equal(newSubscription.Id, user.GatewaySubscriptionId); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); + await _userService.Received(1).SaveUserAsync(user); + } + + [Theory, BitAutoData] + public async Task Run_UserWithActiveSubscription_PremiumTrue_ReturnsBadRequest( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_active_123"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + + var existingActiveSubscription = Substitute.For(); + existingActiveSubscription.Id = "sub_active_123"; + existingActiveSubscription.Status = "active"; // NOT a terminal status + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId).Returns(existingActiveSubscription); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Already a premium user.", badRequest.Response); + // Verify no subscription creation was attempted + await _stripeAdapter.DidNotReceive().CreateSubscriptionAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Run_SubscriptionFetchThrows_ProceedsWithCreation( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = "existing_customer_123"; + user.GatewaySubscriptionId = "sub_nonexistent_123"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + // Simulate Stripe exception when fetching subscription (e.g., subscription doesn't exist) + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId) + .Returns(_ => throw new Stripe.StripeException("Subscription not found")); + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "existing_customer_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var newSubscription = Substitute.For(); + newSubscription.Id = "sub_new_123"; + newSubscription.Status = "active"; + newSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; + + _hasPaymentMethodQuery.Run(Arg.Any()).Returns(true); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert - Should proceed successfully despite the exception + Assert.True(result.IsT0); + Assert.True(user.Premium); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); + await _userService.Received(1).SaveUserAsync(user); + } + + [Theory, BitAutoData] + public async Task Run_ResubscribeWithTerminalSubscription_UpdatesPaymentMethod( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewayCustomerId = "existing_customer_123"; + user.GatewaySubscriptionId = "sub_canceled_123"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "new_card_token_456"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var existingCanceledSubscription = Substitute.For(); + existingCanceledSubscription.Id = "sub_canceled_123"; + existingCanceledSubscription.Status = "canceled"; // Terminal status + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "existing_customer_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var newSubscription = Substitute.For(); + newSubscription.Id = "sub_new_123"; + newSubscription.Status = "active"; + newSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; + + MaskedPaymentMethod mockMaskedPaymentMethod = new MaskedCard + { + Brand = "visa", + Last4 = "4567", + Expiration = "12/2026" + }; + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId).Returns(existingCanceledSubscription); + _hasPaymentMethodQuery.Run(Arg.Any()).Returns(true); // Has old payment method + _updatePaymentMethodCommand.Run(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(mockMaskedPaymentMethod); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + // Verify payment method was updated because of terminal subscription + await _updatePaymentMethodCommand.Received(1).Run(user, paymentMethod, billingAddress); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); + await _userService.Received(1).SaveUserAsync(user); + } + } diff --git a/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs b/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs index e0a11741b3..643941da33 100644 --- a/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs +++ b/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs @@ -31,6 +31,30 @@ public class GetBitwardenSubscriptionQueryTests _stripeAdapter); } + [Fact] + public async Task Run_UserWithoutGatewaySubscriptionId_ReturnsNull() + { + var user = CreateUser(); + user.GatewaySubscriptionId = null; + + var result = await _query.Run(user); + + Assert.Null(result); + await _stripeAdapter.DidNotReceive().GetSubscriptionAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Run_UserWithEmptyGatewaySubscriptionId_ReturnsNull() + { + var user = CreateUser(); + user.GatewaySubscriptionId = string.Empty; + + var result = await _query.Run(user); + + Assert.Null(result); + await _stripeAdapter.DidNotReceive().GetSubscriptionAsync(Arg.Any(), Arg.Any()); + } + [Fact] public async Task Run_IncompleteStatus_ReturnsBitwardenSubscriptionWithSuspension() { diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 073379820e..9d83674f44 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -25,15 +25,11 @@ using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; -using Fido2NetLib; -using Fido2NetLib.Objects; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; using NSubstitute; using Xunit; -using static Fido2NetLib.Fido2; -using GlobalSettings = Bit.Core.Settings.GlobalSettings; namespace Bit.Core.Test.Services; @@ -598,209 +594,6 @@ public class UserServiceTests user.MasterPassword = null; } } - - [Theory] - [BitAutoData(true)] - [BitAutoData(false)] - public async Task StartWebAuthnRegistrationAsync_BelowLimit_Succeeds( - bool hasPremium, SutProvider sutProvider, User user) - { - // Arrange - Non-premium user with 4 credentials (below limit of 5) - SetupWebAuthnProvider(user, credentialCount: 4); - - sutProvider.GetDependency().WebAuthn = new GlobalSettings.WebAuthnSettings - { - PremiumMaximumAllowedCredentials = 10, - NonPremiumMaximumAllowedCredentials = 5 - }; - - user.Premium = hasPremium; - user.Id = Guid.NewGuid(); - user.Email = "test@example.com"; - - sutProvider.GetDependency() - .GetManyByUserAsync(user.Id) - .Returns(new List()); - - var mockFido2 = sutProvider.GetDependency(); - mockFido2.RequestNewCredential( - Arg.Any(), - Arg.Any>(), - Arg.Any(), - Arg.Any()) - .Returns(new CredentialCreateOptions - { - Challenge = new byte[] { 1, 2, 3 }, - Rp = new PublicKeyCredentialRpEntity("example.com", "example.com", ""), - User = new Fido2User - { - Id = user.Id.ToByteArray(), - Name = user.Email, - DisplayName = user.Name - }, - PubKeyCredParams = new List() - }); - - // Act - var result = await sutProvider.Sut.StartWebAuthnRegistrationAsync(user); - - // Assert - Assert.NotNull(result); - await sutProvider.GetDependency().Received(1).ReplaceAsync(user); - } - - [Theory] - [BitAutoData(true)] - [BitAutoData(false)] - public async Task CompleteWebAuthRegistrationAsync_ExceedsLimit_ThrowsBadRequestException(bool hasPremium, - SutProvider sutProvider, User user, AuthenticatorAttestationRawResponse deviceResponse) - { - // Arrange - time-of-check/time-of-use scenario: user now has 10 credentials (at limit) - SetupWebAuthnProviderWithPending(user, credentialCount: 10); - - sutProvider.GetDependency().WebAuthn = new GlobalSettings.WebAuthnSettings - { - PremiumMaximumAllowedCredentials = 10, - NonPremiumMaximumAllowedCredentials = 5 - }; - - user.Premium = hasPremium; - sutProvider.GetDependency() - .GetManyByUserAsync(user.Id) - .Returns(new List()); - - // Act & Assert - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CompleteWebAuthRegistrationAsync(user, 11, "NewKey", deviceResponse)); - - Assert.Equal("Maximum allowed WebAuthn credential count exceeded.", exception.Message); - } - - [Theory] - [BitAutoData(true)] - [BitAutoData(false)] - public async Task CompleteWebAuthRegistrationAsync_BelowLimit_Succeeds(bool hasPremium, - SutProvider sutProvider, User user, AuthenticatorAttestationRawResponse deviceResponse) - { - // Arrange - User has 4 credentials (below limit of 5) - SetupWebAuthnProviderWithPending(user, credentialCount: 4); - - sutProvider.GetDependency().WebAuthn = new GlobalSettings.WebAuthnSettings - { - PremiumMaximumAllowedCredentials = 10, - NonPremiumMaximumAllowedCredentials = 5 - }; - - user.Premium = hasPremium; - user.Id = Guid.NewGuid(); - - sutProvider.GetDependency() - .GetManyByUserAsync(user.Id) - .Returns(new List()); - - var mockFido2 = sutProvider.GetDependency(); - mockFido2.MakeNewCredentialAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(new CredentialMakeResult("ok", "", new AttestationVerificationSuccess - { - Aaguid = Guid.NewGuid(), - Counter = 0, - CredentialId = new byte[] { 1, 2, 3 }, - CredType = "public-key", - PublicKey = new byte[] { 4, 5, 6 }, - Status = "ok", - User = new Fido2User - { - Id = user.Id.ToByteArray(), - Name = user.Email ?? "test@example.com", - DisplayName = user.Name ?? "Test User" - } - })); - - // Act - var result = await sutProvider.Sut.CompleteWebAuthRegistrationAsync(user, 5, "NewKey", deviceResponse); - - // Assert - Assert.True(result); - await sutProvider.GetDependency().Received(1).ReplaceAsync(user); - } - - private static void SetupWebAuthnProvider(User user, int credentialCount) - { - var providers = new Dictionary(); - var metadata = new Dictionary(); - - // Add credentials as Key1, Key2, Key3, etc. - for (int i = 1; i <= credentialCount; i++) - { - metadata[$"Key{i}"] = new TwoFactorProvider.WebAuthnData - { - Name = $"Key {i}", - Descriptor = new PublicKeyCredentialDescriptor(new byte[] { (byte)i }), - PublicKey = new byte[] { (byte)i }, - UserHandle = new byte[] { (byte)i }, - SignatureCounter = 0, - CredType = "public-key", - RegDate = DateTime.UtcNow, - AaGuid = Guid.NewGuid() - }; - } - - providers[TwoFactorProviderType.WebAuthn] = new TwoFactorProvider - { - Enabled = true, - MetaData = metadata - }; - - user.SetTwoFactorProviders(providers); - } - - private static void SetupWebAuthnProviderWithPending(User user, int credentialCount) - { - var providers = new Dictionary(); - var metadata = new Dictionary(); - - // Add existing credentials - for (int i = 1; i <= credentialCount; i++) - { - metadata[$"Key{i}"] = new TwoFactorProvider.WebAuthnData - { - Name = $"Key {i}", - Descriptor = new PublicKeyCredentialDescriptor(new byte[] { (byte)i }), - PublicKey = new byte[] { (byte)i }, - UserHandle = new byte[] { (byte)i }, - SignatureCounter = 0, - CredType = "public-key", - RegDate = DateTime.UtcNow, - AaGuid = Guid.NewGuid() - }; - } - - // Add pending registration - var pendingOptions = new CredentialCreateOptions - { - Challenge = new byte[] { 1, 2, 3 }, - Rp = new PublicKeyCredentialRpEntity("example.com", "example.com", ""), - User = new Fido2User - { - Id = user.Id.ToByteArray(), - Name = user.Email ?? "test@example.com", - DisplayName = user.Name ?? "Test User" - }, - PubKeyCredParams = new List() - }; - metadata["pending"] = pendingOptions.ToJson(); - - providers[TwoFactorProviderType.WebAuthn] = new TwoFactorProvider - { - Enabled = true, - MetaData = metadata - }; - - user.SetTwoFactorProviders(providers); - } } public static class UserServiceSutProviderExtensions diff --git a/util/Seeder/IQuery.cs b/util/Seeder/IQuery.cs index adbcd8e59d..c21b6ebd1d 100644 --- a/util/Seeder/IQuery.cs +++ b/util/Seeder/IQuery.cs @@ -4,8 +4,8 @@ /// Base interface for query operations in the seeding system. The base interface should not be used directly, rather use `IQuery<TRequest, TResult>`. /// /// -/// Queries are synchronous, read-only operations that retrieve data from the seeding context. -/// Unlike scenes which create data, queries fetch existing data based on request parameters. +/// Queries are read-only operations that retrieve data from the seeding context. +/// Unlike scenes, which create data, queries fetch existing data based on request parameters. /// They follow a type-safe pattern using generics to ensure proper request/response handling /// while maintaining a common non-generic interface for dynamic invocation. /// @@ -22,17 +22,17 @@ public interface IQuery /// /// The request object containing parameters for the query operation. /// The query result data as an object. - object Execute(object request); + Task Execute(object request); } /// -/// Generic query interface for synchronous, read-only operations with specific request and result types. +/// Generic query interface for read-only operations with specific request and result types. /// /// The type of request object this query accepts. /// The type of data this query returns. /// /// Use this interface when you need to retrieve existing data from the seeding context based on -/// specific request parameters. Queries are synchronous and do not modify data - they only read +/// specific request parameters. Queries do not modify data - they only read /// and return information. The explicit interface implementations allow dynamic invocation while /// maintaining type safety in the implementation. /// @@ -43,7 +43,7 @@ public interface IQuery : IQuery where TRequest : class where /// /// The request object containing parameters for the query operation. /// The typed query result data. - TResult Execute(TRequest request); + Task Execute(TRequest request); /// /// Gets the request type for this query. @@ -56,5 +56,5 @@ public interface IQuery : IQuery where TRequest : class where /// /// The request object to cast and process. /// The typed result cast to object. - object IQuery.Execute(object request) => Execute((TRequest)request); + async Task IQuery.Execute(object request) => await Execute((TRequest)request); } diff --git a/util/Seeder/Queries/EmergencyAccessInviteQuery.cs b/util/Seeder/Queries/EmergencyAccessInviteQuery.cs index 95d96a9a50..d43311b310 100644 --- a/util/Seeder/Queries/EmergencyAccessInviteQuery.cs +++ b/util/Seeder/Queries/EmergencyAccessInviteQuery.cs @@ -19,7 +19,7 @@ public class EmergencyAccessInviteQuery( public required string Email { get; set; } } - public IEnumerable Execute(Request request) + public Task> Execute(Request request) { var invites = db.EmergencyAccesses .Where(ea => ea.Email == request.Email).ToList().Select(ea => @@ -30,6 +30,6 @@ public class EmergencyAccessInviteQuery( return $"/accept-emergency?id={ea.Id}&name=Dummy&email={ea.Email}&token={token}"; }); - return invites; + return Task.FromResult(invites); } } diff --git a/util/Seeder/Queries/UserEmailVerificationQuery.cs b/util/Seeder/Queries/UserEmailVerificationQuery.cs new file mode 100644 index 0000000000..ae4ab287ca --- /dev/null +++ b/util/Seeder/Queries/UserEmailVerificationQuery.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using System.Net; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Repositories; +using Bit.Core.Tokens; + +namespace Bit.Seeder.Queries; + +public class UserEmailVerificationQuery(IUserRepository userRepository, + IDataProtectorTokenFactory dataProtectorTokenizer) : IQuery +{ + public class Request + { + public string? Name { get; set; } = null; + public required string Email { get; set; } + public string? FromMarketing { get; set; } = null; + public bool ReceiveMarketingEmails { get; set; } = false; + } + + public class Response + { + public required string Url { get; set; } + public required bool EmailVerified { get; set; } + } + + public async Task Execute(Request request) + { + var user = await userRepository.GetByEmailAsync(request.Email); + + var token = generateToken(request.Email, request.Name, request.ReceiveMarketingEmails); + + return new() + { + Url = Url(token, request.Email, request.FromMarketing), + EmailVerified = user?.EmailVerified ?? false + }; + } + + private string Url(string token, string email, string? fromMarketing = null) + { + return string.Format(CultureInfo.InvariantCulture, "/redirect-connector.html#finish-signup?token={0}&email={1}&fromEmail=true{2}", + WebUtility.UrlEncode(token), + WebUtility.UrlEncode(email), + !string.IsNullOrEmpty(fromMarketing) ? $"&fromMarketing={fromMarketing}" : string.Empty); + } + + private string generateToken(string email, string? name, bool receiveMarketingEmails) + { + + return dataProtectorTokenizer.Protect( + new RegistrationEmailVerificationTokenable(email, name, receiveMarketingEmails) + ); + } +} diff --git a/util/SeederApi/Controllers/QueryController.cs b/util/SeederApi/Controllers/QueryController.cs index 22bf84e5b7..676c967384 100644 --- a/util/SeederApi/Controllers/QueryController.cs +++ b/util/SeederApi/Controllers/QueryController.cs @@ -9,13 +9,13 @@ namespace Bit.SeederApi.Controllers; public class QueryController(ILogger logger, IQueryExecutor queryExecutor) : Controller { [HttpPost] - public IActionResult Query([FromBody] QueryRequestModel request) + public async Task Query([FromBody] QueryRequestModel request) { logger.LogInformation("Executing query: {Query}", request.Template); try { - var result = queryExecutor.Execute(request.Template, request.Arguments); + var result = await queryExecutor.Execute(request.Template, request.Arguments); return Json(result); } diff --git a/util/SeederApi/Execution/IQueryExecutor.cs b/util/SeederApi/Execution/IQueryExecutor.cs index ebd971bbb7..53343433c6 100644 --- a/util/SeederApi/Execution/IQueryExecutor.cs +++ b/util/SeederApi/Execution/IQueryExecutor.cs @@ -18,5 +18,5 @@ public interface IQueryExecutor /// The result of the query execution /// Thrown when the query is not found /// Thrown when there's an error executing the query - object Execute(string queryName, JsonElement? arguments); + Task Execute(string queryName, JsonElement? arguments); } diff --git a/util/SeederApi/Execution/QueryExecutor.cs b/util/SeederApi/Execution/QueryExecutor.cs index 5473586c22..5e344aa23d 100644 --- a/util/SeederApi/Execution/QueryExecutor.cs +++ b/util/SeederApi/Execution/QueryExecutor.cs @@ -9,7 +9,7 @@ public class QueryExecutor( IServiceProvider serviceProvider) : IQueryExecutor { - public object Execute(string queryName, JsonElement? arguments) + public async Task Execute(string queryName, JsonElement? arguments) { try { @@ -18,7 +18,7 @@ public class QueryExecutor( var requestType = query.GetRequestType(); var requestModel = DeserializeRequestModel(queryName, requestType, arguments); - var result = query.Execute(requestModel); + var result = await query.Execute(requestModel); logger.LogInformation("Successfully executed query: {QueryName}", queryName); return result;