mirror of
https://github.com/bitwarden/server
synced 2026-02-19 19:03:30 +00:00
Merge branch 'main' into auth/pm-30810/http-redirect-cloud
This commit is contained in:
@@ -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<Error> 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)]
|
||||
|
||||
@@ -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<TwoFactorAuthenticatorUserVerificationTokenable> _twoFactorAuthenticatorDataProtector;
|
||||
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _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<TwoFactorAuthenticatorUserVerificationTokenable> twoFactorAuthenticatorDataProtector,
|
||||
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> 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<CredentialCreateOptions> 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;
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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)}";
|
||||
}
|
||||
}
|
||||
@@ -303,7 +303,8 @@ public class Startup
|
||||
{
|
||||
swaggerDoc.Servers =
|
||||
[
|
||||
new() {
|
||||
new()
|
||||
{
|
||||
Url = globalSettings.BaseServiceUri.Api,
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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<IActionResult> 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<IActionResult> 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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Subscription>() 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) =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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<OrgUserInviteTokenable> 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<OrganizationUser> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
|
||||
public interface IPushAutoConfirmNotificationCommand
|
||||
{
|
||||
Task PushAsync(Guid userId, Guid organizationId);
|
||||
}
|
||||
@@ -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<AutoConfirmPushNotification>
|
||||
{
|
||||
Target = NotificationTarget.User,
|
||||
TargetId = adminUserId,
|
||||
Type = PushType.AutoConfirm,
|
||||
Payload = new AutoConfirmPushNotification
|
||||
{
|
||||
UserId = adminUserId,
|
||||
OrganizationId = organizationId,
|
||||
TargetUserId = organizationUser.Id
|
||||
},
|
||||
ExcludeCurrentContext = false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ValidationResult<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>>
|
||||
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<Error?> ValidateUserComplianceWithSingleOrgAsync(
|
||||
AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest request,
|
||||
ICollection<OrganizationUserUserDetails> 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<Error?> ValidateNoProviderUsersAsync(ICollection<OrganizationUserUserDetails> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
|
||||
|
||||
public record AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(Guid OrganizationId);
|
||||
@@ -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.");
|
||||
@@ -0,0 +1,28 @@
|
||||
using Bit.Core.AdminConsole.Utilities.v2.Validation;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that an organization meets the prerequisites for enabling the Automatic User Confirmation policy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The following conditions must be met:
|
||||
/// <list type="bullet">
|
||||
/// <item>All non-invited organization users belong only to this organization (Single Organization compliance)</item>
|
||||
/// <item>No organization users are provider members</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public interface IAutomaticUserConfirmationOrganizationPolicyComplianceValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks whether the organization is compliant with the Automatic User Confirmation policy prerequisites.
|
||||
/// </summary>
|
||||
/// <param name="request">The request containing the organization ID to validate.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="ValidationResult{TRequest}"/> that is valid if the organization is compliant,
|
||||
/// or contains a <see cref="UserNotCompliantWithSingleOrganization"/> or <see cref="ProviderExistsInOrganization"/>
|
||||
/// error if validation fails.
|
||||
/// </returns>
|
||||
Task<ValidationResult<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>>
|
||||
IsOrganizationCompliantAsync(AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest request);
|
||||
}
|
||||
@@ -21,12 +21,14 @@ public static class PolicyServiceCollectionExtensions
|
||||
services.AddScoped<IPolicyQuery, PolicyQuery>();
|
||||
services.AddScoped<IPolicyEventHandlerFactory, PolicyEventHandlerHandlerFactory>();
|
||||
|
||||
services.AddScoped<IAutomaticUserConfirmationPolicyEnforcementValidator, AutomaticUserConfirmationPolicyEnforcementValidator>();
|
||||
services.AddScoped<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator, AutomaticUserConfirmationOrganizationPolicyComplianceValidator>();
|
||||
|
||||
services.AddPolicyValidators();
|
||||
services.AddPolicyRequirements();
|
||||
services.AddPolicySideEffects();
|
||||
services.AddPolicyUpdateEvents();
|
||||
|
||||
services.AddScoped<IAutomaticUserConfirmationPolicyEnforcementValidator, AutomaticUserConfirmationPolicyEnforcementValidator>();
|
||||
}
|
||||
|
||||
[Obsolete("Use AddPolicyUpdateEvents instead.")]
|
||||
|
||||
@@ -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;
|
||||
/// <li>No provider users exist</li>
|
||||
/// </ul>
|
||||
/// </summary>
|
||||
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<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
|
||||
|
||||
public async Task<string> 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<string> ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) =>
|
||||
@@ -51,48 +44,4 @@ public class AutomaticUserConfirmationPolicyEventHandler(
|
||||
|
||||
public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
private async Task<string> 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<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId,
|
||||
ICollection<OrganizationUserUserDetails> 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<string> ValidateNoProviderUsersAsync(ICollection<OrganizationUserUserDetails> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> 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 })
|
||||
{
|
||||
|
||||
@@ -81,7 +81,6 @@ public class RegisterUserCommand : IRegisterUserCommand
|
||||
_emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory;
|
||||
|
||||
_providerServiceDataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
public async Task<IdentityResult> 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(
|
||||
|
||||
@@ -22,7 +22,6 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _tokenDataFactory;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IOrganizationDomainRepository _organizationDomainRepository;
|
||||
|
||||
public SendVerificationEmailForRegistrationCommand(
|
||||
@@ -31,7 +30,6 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
|
||||
GlobalSettings globalSettings,
|
||||
IMailService mailService,
|
||||
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> 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
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using Bit.Core.Entities;
|
||||
using Fido2NetLib;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;
|
||||
|
||||
public interface ICompleteTwoFactorWebAuthnRegistrationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Enshrines WebAuthn 2FA credential registration after a successful challenge.
|
||||
/// </summary>
|
||||
/// <param name="user">The current user.</param>
|
||||
/// <param name="id">ID for the Key credential to complete.</param>
|
||||
/// <param name="name">Name for the Key credential to complete.</param>
|
||||
/// <param name="attestationResponse">WebAuthn attestation response.</param>
|
||||
/// <returns>Whether persisting the credential was successful.</returns>
|
||||
Task<bool> CompleteTwoFactorWebAuthnRegistrationAsync(User user, int id, string name,
|
||||
AuthenticatorAttestationRawResponse attestationResponse);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;
|
||||
|
||||
public interface IDeleteTwoFactorWebAuthnCredentialCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Deletes a single Two-factor WebAuthn credential by ID ("Key{id}").
|
||||
/// </summary>
|
||||
/// <param name="user">The current user.</param>
|
||||
/// <param name="id">ID of the credential to delete ("Key{id}").</param>
|
||||
/// <returns>Whether deletion was successful.</returns>
|
||||
/// <remarks>Will not delete the last registered credential for a user. To delete the last (or single)
|
||||
/// registered credential, use <see cref="IUserService.DisableTwoFactorProviderAsync"/></remarks>
|
||||
Task<bool> DeleteTwoFactorWebAuthnCredentialAsync(User user, int id);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Fido2NetLib;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;
|
||||
|
||||
public interface IStartTwoFactorWebAuthnRegistrationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Initiates WebAuthn 2FA credential registration and generates a challenge for adding a new security key.
|
||||
/// </summary>
|
||||
/// <param name="user">The current user.</param>
|
||||
/// <returns>Options for creating a new WebAuthn 2FA credential</returns>
|
||||
/// <exception cref="BadRequestException">Maximum allowed number of credentials already registered.</exception>
|
||||
Task<CredentialCreateOptions> StartTwoFactorWebAuthnRegistrationAsync(User user);
|
||||
}
|
||||
@@ -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<bool> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<bool> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<CredentialCreateOptions> StartTwoFactorWebAuthnRegistrationAsync(User user)
|
||||
{
|
||||
var providers = user.GetTwoFactorProviders() ?? new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn) ??
|
||||
new TwoFactorProvider { Enabled = false };
|
||||
provider.MetaData ??= new Dictionary<string, object>();
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
@@ -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<IAssertWebAuthnLoginCredentialCommand, AssertWebAuthnLoginCredentialCommand>();
|
||||
}
|
||||
|
||||
private static void AddTwoFactorQueries(this IServiceCollection services)
|
||||
private static void AddTwoFactorCommandsQueries(this IServiceCollection services)
|
||||
{
|
||||
services
|
||||
.AddScoped<ICompleteTwoFactorWebAuthnRegistrationCommand, CompleteTwoFactorWebAuthnRegistrationCommand>();
|
||||
services
|
||||
.AddScoped<IStartTwoFactorWebAuthnRegistrationCommand,
|
||||
StartTwoFactorWebAuthnRegistrationCommand>();
|
||||
services.AddScoped<IDeleteTwoFactorWebAuthnCredentialCommand, DeleteTwoFactorWebAuthnCredentialCommand>();
|
||||
services.AddScoped<ITwoFactorIsEnabledQuery, TwoFactorIsEnabledQuery>();
|
||||
}
|
||||
|
||||
|
||||
@@ -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!);
|
||||
|
||||
@@ -72,7 +72,13 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
||||
BillingAddress billingAddress,
|
||||
short additionalStorageGb) => HandleAsync<None>(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<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ public interface IGetBitwardenSubscriptionQuery
|
||||
/// Currently only supports <see cref="User"/> subscribers. Future versions will support all
|
||||
/// <see cref="ISubscriber"/> types (User and Organization).
|
||||
/// </remarks>
|
||||
Task<BitwardenSubscription> Run(User user);
|
||||
Task<BitwardenSubscription?> Run(User user);
|
||||
}
|
||||
|
||||
public class GetBitwardenSubscriptionQuery(
|
||||
@@ -39,8 +39,13 @@ public class GetBitwardenSubscriptionQuery(
|
||||
IPricingClient pricingClient,
|
||||
IStripeAdapter stripeAdapter) : IGetBitwardenSubscriptionQuery
|
||||
{
|
||||
public async Task<BitwardenSubscription> Run(User user)
|
||||
public async Task<BitwardenSubscription?> Run(User user)
|
||||
{
|
||||
if (string.IsNullOrEmpty(user.GatewaySubscriptionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions
|
||||
{
|
||||
Expand =
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,50 +1,51 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-include path="../../../components/head.mjml" />
|
||||
</mj-head>
|
||||
<mj-head>
|
||||
<!-- Include shared head styles -->
|
||||
<mj-include path="../../../components/head.mjml" />
|
||||
|
||||
<mj-body css-class="border-fix">
|
||||
<!-- Blue Header Section -->
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/spot-enterprise.png"
|
||||
title="You can now share passwords with members of <b>{{OrganizationName}}!</b>"
|
||||
button-text="<b>Log in</b>"
|
||||
button-url="{{WebVaultUrl}}"
|
||||
/>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Main Content -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-section background-color="#fff" padding="15px 10px 10px 10px">
|
||||
<mj-column>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
|
||||
As a member of <b>{{OrganizationName}}</b>:
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-enterprise.png"
|
||||
icon-alt="Organization Icon"
|
||||
text="Your account is owned by {{OrganizationName}} and is subject to their security and management policies."
|
||||
/>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-account-switching-new.png"
|
||||
icon-alt="Group Users Icon"
|
||||
text="You can easily access and share passwords with your team."
|
||||
foot-url-text="Share passwords in Bitwarden"
|
||||
foot-url="https://bitwarden.com/help/sharing"
|
||||
/>
|
||||
<mj-section background-color="#fff" padding="0 20px 20px 20px">
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
</mj-head>
|
||||
|
||||
<!-- Learn More Section -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-bw-learn-more-footer />
|
||||
</mj-wrapper>
|
||||
<mj-body>
|
||||
<!-- Blue Header Section -->
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
|
||||
<mj-bw-ac-hero
|
||||
title="You can now share passwords with members of <b>{{OrganizationName}}!</b>"
|
||||
img-src="https://assets.bitwarden.com/email/v1/ac-spot-enterprise.png"
|
||||
button-text="<b>Log in</b>"
|
||||
button-url="{{WebVaultUrl}}"
|
||||
/>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Footer -->
|
||||
<mj-include path="../../../components/footer.mjml" />
|
||||
</mj-body>
|
||||
<!-- Main Content -->
|
||||
<mj-wrapper padding="5px 20px 8px 20px">
|
||||
<mj-section background-color="#fff" padding="10px 10px 16px 10px">
|
||||
<mj-column>
|
||||
<mj-text font-size="16px" line-height="24px" padding="15px 15px 0px 15px">
|
||||
As a member of <b>{{OrganizationName}}</b>:
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-bw-ac-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-enterprise.png"
|
||||
icon-alt="Organization Icon"
|
||||
text="Your account is owned by {{OrganizationName}} and is subject to their security and management policies."
|
||||
/>
|
||||
<mj-bw-ac-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-account-switching-new.png"
|
||||
icon-alt="Share Icon"
|
||||
text="You can easily access and share passwords with your team."
|
||||
foot-url-text="Share passwords in Bitwarden"
|
||||
foot-url="https://bitwarden.com/help/sharing"
|
||||
/>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Learn More Section -->
|
||||
<mj-wrapper padding="8px 20px 10px 20px">
|
||||
<mj-bw-ac-learn-more-footer />
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Footer -->
|
||||
<mj-include path="../../../components/footer.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
|
||||
@@ -1,55 +1,104 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-include path="../../../components/head.mjml" />
|
||||
</mj-head>
|
||||
<mj-head>
|
||||
<!-- Include shared head styles -->
|
||||
<mj-include path="../../../components/head.mjml" />
|
||||
|
||||
<mj-body css-class="border-fix">
|
||||
<!-- Blue Header Section -->
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/spot-family-homes.png"
|
||||
title="You can now share passwords with members of <b>{{OrganizationName}}!</b>"
|
||||
button-text="<b>Log in</b>"
|
||||
button-url="{{WebVaultUrl}}"
|
||||
/>
|
||||
</mj-wrapper>
|
||||
<!-- Include admin console shared styles --><mj-include path="../components/admin-console-head.mjml" />
|
||||
</mj-head>
|
||||
|
||||
<!-- Main Content -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-section background-color="#fff" padding="15px 10px 10px 10px">
|
||||
<mj-column>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
|
||||
As a member of <b>{{OrganizationName}}</b>:
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-item-type.png"
|
||||
icon-alt="Collections Icon"
|
||||
text="You can access passwords {{OrganizationName}} has shared with you."
|
||||
/>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-account-switching-new.png"
|
||||
icon-alt="Group Users Icon"
|
||||
text="You can easily share passwords with friends, family, or coworkers."
|
||||
foot-url-text="Share passwords in Bitwarden"
|
||||
foot-url="https://bitwarden.com/help/sharing"
|
||||
/>
|
||||
<mj-section background-color="#fff" padding="0 20px 20px 20px">
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
<mj-body>
|
||||
<!-- Blue Header Section -->
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
|
||||
<mj-bw-ac-hero
|
||||
title="You can now share passwords with members of <b>{{OrganizationName}}!</b>"
|
||||
img-src="https://assets.bitwarden.com/email/v1/ac-spot-family.png"
|
||||
button-text="<b>Log in</b>"
|
||||
button-url="{{WebVaultUrl}}">
|
||||
</mj-bw-ac-hero>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Download Mobile Apps Section -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-include path="../components/mobile-app-download.mjml" />
|
||||
</mj-wrapper>
|
||||
<!-- Main Content -->
|
||||
<mj-wrapper padding="5px 20px 8px 20px">
|
||||
<mj-section background-color="#fff" padding="10px 10px 16px 10px">
|
||||
<mj-column>
|
||||
<mj-text font-size="16px" line-height="24px" padding="15px 15px 0px 15px">
|
||||
As a member of <b>{{OrganizationName}}</b>:
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-bw-ac-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-item-type.png"
|
||||
icon-alt="Group Users Icon"
|
||||
text="You can access passwords {{OrganizationName}} has shared with you.">
|
||||
</mj-bw-ac-icon-row>
|
||||
<mj-bw-ac-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-account-switching-new.png"
|
||||
icon-alt="Share Icon"
|
||||
text="You can easily share passwords with friends, family, or coworkers."
|
||||
foot-url-text="Share passwords in Bitwarden"
|
||||
foot-url="https://bitwarden.com/help/sharing">
|
||||
</mj-bw-ac-icon-row>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Learn More Section -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-bw-learn-more-footer />
|
||||
</mj-wrapper>
|
||||
<!-- Download Mobile Apps Section -->
|
||||
<mj-wrapper padding="8px 20px 10px 20px">
|
||||
<mj-section background-color="#fff" padding="32px 10px 0px 25px">
|
||||
<mj-column>
|
||||
<mj-text
|
||||
font-size="18px"
|
||||
font-weight="500"
|
||||
line-height="24px"
|
||||
padding="0 0 16px 0">
|
||||
Download Bitwarden on all devices
|
||||
</mj-text>
|
||||
|
||||
<!-- Footer -->
|
||||
<mj-include path="../../../components/footer.mjml" />
|
||||
</mj-body>
|
||||
<mj-text
|
||||
mj-class="ac-text"
|
||||
padding="0 0 24px 0">
|
||||
Already using the <a href="https://bitwarden.com/download/" class="link">browser extension</a>?
|
||||
Download the Bitwarden mobile app from the
|
||||
<a href="https://apps.apple.com/us/app/bitwarden-password-manager/id1137397744" class="link">App Store</a>
|
||||
or <a href="https://play.google.com/store/apps/details?id=com.x8bit.bitwarden" class="link">Google Play</a>
|
||||
to quickly save logins and autofill forms on the go.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section background-color="#fff" padding="0 10px 32px 25px">
|
||||
<mj-group>
|
||||
<mj-column width="159px">
|
||||
<mj-image
|
||||
css-class="hide-mobile"
|
||||
href="https://apps.apple.com/us/app/bitwarden-password-manager/id1137397744"
|
||||
src="https://assets.bitwarden.com/email/v1/ac-apple-store.png"
|
||||
alt="Download on the App Store"
|
||||
width="135px"
|
||||
height="40px"
|
||||
padding="0 24px 0 0"
|
||||
/>
|
||||
</mj-column>
|
||||
<mj-column width="140px">
|
||||
<mj-image
|
||||
css-class="hide-mobile"
|
||||
href="https://play.google.com/store/apps/details?id=com.x8bit.bitwarden"
|
||||
src="https://assets.bitwarden.com/email/v1/ac-google-play.png"
|
||||
alt="Get it on Google Play"
|
||||
width="140px"
|
||||
height="40px"
|
||||
padding="0"
|
||||
/>
|
||||
</mj-column>
|
||||
</mj-group>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
|
||||
|
||||
<!-- Learn More Section -->
|
||||
<mj-wrapper padding="8px 20px 10px 20px">
|
||||
<mj-bw-ac-learn-more-footer />
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Footer -->
|
||||
<mj-include path="../../../components/footer.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
<mj-attributes>
|
||||
<mj-all
|
||||
font-family="'Helvetica Neue','Inter',Helvetica,Arial,sans-serif" />
|
||||
<mj-class
|
||||
name="ac-text"
|
||||
font-size="16px"
|
||||
font-weight="400"
|
||||
line-height="24px"
|
||||
/>
|
||||
</mj-attributes>
|
||||
|
||||
<mj-style>
|
||||
@media only screen and (max-width: 480px) {
|
||||
.hide-mobile { display: none !important; }
|
||||
}
|
||||
</mj-style>
|
||||
@@ -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") ?
|
||||
`<mj-button
|
||||
href="${this.getAttribute("button-url")}"
|
||||
background-color="#fff"
|
||||
color="#1A41AC"
|
||||
border-radius="20px"
|
||||
align="left"
|
||||
inner-padding="12px 24px"
|
||||
>
|
||||
${this.getAttribute("button-text")}
|
||||
</mj-button
|
||||
>` : "";
|
||||
const subTitleElement = this.getAttribute("sub-title") ?
|
||||
`<mj-text color="#fff" padding-top="0" padding-bottom="0">
|
||||
<h2 style="font-weight: normal; font-size: 16px; line-height: 0px">
|
||||
${this.getAttribute("sub-title")}
|
||||
</h2>
|
||||
</mj-text>` : "";
|
||||
|
||||
return this.renderMJML(
|
||||
`
|
||||
<mj-section
|
||||
full-width="full-width"
|
||||
background-color="#175ddc"
|
||||
border-radius="4px 4px 0px 0px"
|
||||
>
|
||||
<mj-column width="70%">
|
||||
<mj-image
|
||||
align="left"
|
||||
src="https://bitwarden.com/images/logo-horizontal-white.png"
|
||||
width="150px"
|
||||
height="30px"
|
||||
></mj-image>
|
||||
<mj-text color="#fff" padding-top="0" padding-bottom="0">
|
||||
<h1 style="font-weight: 400; font-size: 24px; line-height: 32px">
|
||||
${this.getAttribute("title")}
|
||||
</h1>
|
||||
` +
|
||||
subTitleElement +
|
||||
`
|
||||
</mj-text>` +
|
||||
buttonElement +
|
||||
`
|
||||
</mj-column>
|
||||
<mj-column width="30%" vertical-align="bottom">
|
||||
<mj-image
|
||||
src="${this.getAttribute("img-src")}"
|
||||
alt=""
|
||||
width="155px"
|
||||
padding="0px 20px 0px 0px"
|
||||
align="right"
|
||||
css-class="mj-bw-ac-hero-responsive-img"
|
||||
/>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MjBwAcHero;
|
||||
@@ -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")
|
||||
? `
|
||||
<mj-text css-class="mj-bw-ac-icon-row-text" padding="5px 10px 0px 10px" ${BODY_TEXT_STYLES}>
|
||||
<a href="${this.getAttribute("head-url")}" class="link">
|
||||
${this.getAttribute("head-url-text")}
|
||||
<span style="text-decoration: none">
|
||||
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png"
|
||||
alt="External Link Icon"
|
||||
width="16px"
|
||||
style="vertical-align: middle;"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</mj-text>`
|
||||
: "";
|
||||
|
||||
const footAnchorElement =
|
||||
this.getAttribute("foot-url-text") && this.getAttribute("foot-url")
|
||||
? `<mj-text css-class="mj-bw-ac-icon-row-text" padding="0px" ${BODY_TEXT_STYLES}>
|
||||
<a href="${this.getAttribute("foot-url")}" class="link">
|
||||
${this.getAttribute("foot-url-text")}
|
||||
</a>
|
||||
</mj-text>`
|
||||
: "";
|
||||
|
||||
return this.renderMJML(
|
||||
`
|
||||
<mj-section background-color="#fff" padding="0px 10px 24px 10px">
|
||||
<mj-group css-class="mj-bw-ac-icon-row">
|
||||
<mj-column width="15%" vertical-align="middle" css-class="mj-bw-ac-icon-row-icon">
|
||||
<mj-image
|
||||
src="${this.getAttribute("icon-src")}"
|
||||
alt="${this.getAttribute("icon-alt")}"
|
||||
width="48px"
|
||||
padding="0px 10px 0px 5px"
|
||||
border-radius="8px"
|
||||
/>
|
||||
</mj-column>
|
||||
<mj-column width="85%" vertical-align="middle" css-class="mj-bw-ac-icon-row-text-column">
|
||||
${headAnchorElement}
|
||||
<mj-text css-class="mj-bw-ac-icon-row-text" padding="0px 0px 0px 0px" ${BODY_TEXT_STYLES}>
|
||||
${this.getAttribute("text")}
|
||||
</mj-text>
|
||||
${footAnchorElement}
|
||||
</mj-column>
|
||||
</mj-group>
|
||||
</mj-section>
|
||||
`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MjBwAcIconRow;
|
||||
@@ -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(
|
||||
`
|
||||
<mj-section border-radius="0px 0px 4px 4px" background-color="#F3F6F9" padding="14px 10px 14px 10px">
|
||||
<mj-column width="70%">
|
||||
<mj-text padding="10px 15px 10px 15px">
|
||||
<p style="font-size: 18px; line-height: 28px; font-weight: 500; margin: 0 0 8px 0;">
|
||||
Learn more about Bitwarden
|
||||
</p>
|
||||
<p style="font-size: 16px; line-height: 24px; margin: 0;">
|
||||
Find user guides, product documentation, and videos on the
|
||||
<a href="https://bitwarden.com/help/" class="link"> Bitwarden Help Center</a>.
|
||||
</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
<mj-column width="30%" vertical-align="bottom">
|
||||
<mj-image
|
||||
src="https://assets.bitwarden.com/email/v1/spot-community.png"
|
||||
css-class="mj-bw-ac-learn-more-footer-responsive-img"
|
||||
width="94px"
|
||||
padding="0px 15px 0px 0px"
|
||||
align="right"
|
||||
/>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MjBwAcLearnMoreFooter;
|
||||
@@ -110,3 +110,21 @@ public class SyncPolicyPushNotification
|
||||
public Guid OrganizationId { get; set; }
|
||||
public required Policy Policy { get; set; }
|
||||
}
|
||||
|
||||
public class AutoConfirmPushNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// The admin/owner receiving this notification
|
||||
/// </summary>
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The organization the user accepted an invite to
|
||||
/// </summary>
|
||||
public Guid OrganizationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The user who accepted the organization invite (will be auto-confirmed)
|
||||
/// </summary>
|
||||
public Guid TargetUserId { get; set; }
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ public static class OrganizationServiceCollectionExtensions
|
||||
{
|
||||
services.AddScoped<ICountNewSmSeatsRequiredQuery, CountNewSmSeatsRequiredQuery>();
|
||||
services.AddScoped<IAcceptOrgUserCommand, AcceptOrgUserCommand>();
|
||||
services.AddScoped<IPushAutoConfirmNotificationCommand, PushAutoConfirmNotificationCommand>();
|
||||
services.AddScoped<IOrganizationUserUserDetailsQuery, OrganizationUserUserDetailsQuery>();
|
||||
services.AddScoped<IGetOrganizationUsersClaimedStatusQuery, GetOrganizationUsersClaimedStatusQuery>();
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<IdentityResult> CreateUserAsync(User user);
|
||||
Task<IdentityResult> CreateUserAsync(User user, string masterPasswordHash);
|
||||
Task SendMasterPasswordHintAsync(string email);
|
||||
Task<CredentialCreateOptions> StartWebAuthnRegistrationAsync(User user);
|
||||
Task<bool> DeleteWebAuthnKeyAsync(User user, int id);
|
||||
Task<bool> CompleteWebAuthRegistrationAsync(User user, int value, string name, AuthenticatorAttestationRawResponse attestationResponse);
|
||||
Task SendEmailVerificationAsync(User user);
|
||||
Task<IdentityResult> ConfirmEmailAsync(User user, string token);
|
||||
Task InitiateEmailChangeAsync(User user, string newEmail);
|
||||
|
||||
@@ -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<User>, IUserService
|
||||
await _mailService.SendMasterPasswordHintEmailAsync(email, user.MasterPasswordHint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initiates WebAuthn 2FA credential registration and generates a challenge for adding a new security key.
|
||||
/// </summary>
|
||||
/// <param name="user">The current user.</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="BadRequestException">Maximum allowed number of credentials already registered.</exception>
|
||||
public async Task<CredentialCreateOptions> StartWebAuthnRegistrationAsync(User user)
|
||||
{
|
||||
var providers = user.GetTwoFactorProviders();
|
||||
if (providers == null)
|
||||
{
|
||||
providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
||||
}
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn);
|
||||
if (provider == null)
|
||||
{
|
||||
provider = new TwoFactorProvider
|
||||
{
|
||||
Enabled = false
|
||||
};
|
||||
}
|
||||
if (provider.MetaData == null)
|
||||
{
|
||||
provider.MetaData = new Dictionary<string, object>();
|
||||
}
|
||||
|
||||
// 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<bool> 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<bool> 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)
|
||||
|
||||
@@ -234,6 +234,18 @@ public class HubHelpers
|
||||
case PushType.PolicyChanged:
|
||||
await policyChangedNotificationHandler(notificationJson, cancellationToken);
|
||||
break;
|
||||
case PushType.AutoConfirm:
|
||||
var autoConfirmNotification =
|
||||
JsonSerializer.Deserialize<PushNotificationData<AutoConfirmPushNotification>>(
|
||||
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;
|
||||
|
||||
@@ -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<IOrganizationRepository>();
|
||||
|
||||
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organization.Id);
|
||||
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
|
||||
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>())
|
||||
.Returns(Valid(request));
|
||||
|
||||
// Act
|
||||
_ = await sutProvider.Sut.Edit(organization.Id, update);
|
||||
|
||||
// Assert
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(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<OrganizationsController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var update = new OrganizationEditModel
|
||||
{
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
UseAutomaticUserConfirmation = true
|
||||
};
|
||||
|
||||
organization.UseAutomaticUserConfirmation = false;
|
||||
|
||||
sutProvider.GetDependency<IAccessControlService>()
|
||||
.UserHasPermission(Permission.Org_Plan_Edit)
|
||||
.Returns(true);
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organization.Id);
|
||||
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
|
||||
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>())
|
||||
.Returns(Invalid(request, new UserNotCompliantWithSingleOrganization()));
|
||||
|
||||
sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.Edit(organization.Id, update);
|
||||
|
||||
// Assert
|
||||
var redirectResult = Assert.IsType<RedirectToActionResult>(result);
|
||||
Assert.Equal("Edit", redirectResult.ActionName);
|
||||
Assert.Equal(organization.Id, redirectResult.RouteValues!["id"]);
|
||||
|
||||
await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task Edit_EnableUseAutomaticUserConfirmation_ProviderValidationFails_RedirectsWithError(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationsController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var update = new OrganizationEditModel
|
||||
{
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
UseAutomaticUserConfirmation = true
|
||||
};
|
||||
|
||||
organization.UseAutomaticUserConfirmation = false;
|
||||
|
||||
sutProvider.GetDependency<IAccessControlService>()
|
||||
.UserHasPermission(Permission.Org_Plan_Edit)
|
||||
.Returns(true);
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organization.Id);
|
||||
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
|
||||
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>())
|
||||
.Returns(Invalid(request, new ProviderExistsInOrganization()));
|
||||
|
||||
sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.Edit(organization.Id, update);
|
||||
|
||||
// Assert
|
||||
var redirectResult = Assert.IsType<RedirectToActionResult>(result);
|
||||
Assert.Equal("Edit", redirectResult.ActionName);
|
||||
|
||||
await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task Edit_UseAutomaticUserConfirmation_NotChanged_DoesNotCallValidator(
|
||||
SutProvider<OrganizationsController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = new Guid();
|
||||
var update = new OrganizationEditModel
|
||||
{
|
||||
UseSecretsManager = false,
|
||||
UseAutomaticUserConfirmation = false
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
UseAutomaticUserConfirmation = false
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
|
||||
.DidNotReceive()
|
||||
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>());
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task Edit_UseAutomaticUserConfirmation_AlreadyEnabled_DoesNotCallValidator(
|
||||
SutProvider<OrganizationsController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = new Guid();
|
||||
var update = new OrganizationEditModel
|
||||
{
|
||||
UseSecretsManager = false,
|
||||
UseAutomaticUserConfirmation = true
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
UseAutomaticUserConfirmation = true
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
_ = await sutProvider.Sut.Edit(organizationId, update);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
|
||||
.DidNotReceive()
|
||||
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -162,7 +162,7 @@ public class OrganizationUserControllerAutoConfirmTests : IClassFixture<ApiAppli
|
||||
|
||||
var testKey = $"test-key-{Guid.NewGuid()}";
|
||||
|
||||
var userToConfirmEmail = $"org-user-to-confirm-{Guid.NewGuid()}@example.com";
|
||||
var userToConfirmEmail = $"org-user-to-confirm-{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(userToConfirmEmail);
|
||||
|
||||
await _factory.GetService<IPolicyRepository>().CreateAsync(new Policy
|
||||
@@ -190,15 +190,17 @@ public class OrganizationUserControllerAutoConfirmTests : IClassFixture<ApiAppli
|
||||
new Permissions(),
|
||||
OrganizationUserStatusType.Accepted);
|
||||
|
||||
var tenRequests = Enumerable.Range(0, 10)
|
||||
.Select(_ => _client.PostAsJsonAsync($"organizations/{organization.Id}/users/{organizationUser.Id}/auto-confirm",
|
||||
var results = new List<HttpResponseMessage>();
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<RedirectResult>(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<RedirectResult>(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<string, string>
|
||||
@@ -289,7 +289,7 @@ public class SsoCookieVendorControllerTests : IDisposable
|
||||
|
||||
// Assert
|
||||
var redirectResult = Assert.IsType<RedirectResult>(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]
|
||||
@@ -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<string, string>());
|
||||
send.AuthType = AuthType.None;
|
||||
send.Emails = null;
|
||||
send.HideEmail = true;
|
||||
|
||||
_sendRepository.GetByIdAsync(Arg.Any<Guid>()).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<ClaimsPrincipal>()).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<Send>(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<ClaimsPrincipal>()).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<Send>(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<NotFoundException>(() => _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<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithNonExistentSend_ThrowsBadRequestException(
|
||||
Guid sendId, string fileId)
|
||||
|
||||
@@ -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<string, string> { { "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<Provider>());
|
||||
await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
||||
// 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<SubscriptionUpdateOptions>(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<string, string> { { "userId", userId.ToString() } },
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
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<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert
|
||||
await _userService.Received(1).DisablePremiumAsync(userId, currentPeriodEnd);
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
subscriptionId,
|
||||
Arg.Is<SubscriptionUpdateOptions>(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<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = currentPeriodEnd,
|
||||
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
|
||||
}
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "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<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
|
||||
.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<SubscriptionUpdateOptions>(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<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
|
||||
.Returns(subscription);
|
||||
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(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<string>(), Arg.Any<SubscriptionCancelOptions>());
|
||||
await _stripeFacade.DidNotReceive()
|
||||
.ListInvoices(Arg.Any<InvoiceListOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -527,6 +666,9 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
|
||||
.Returns(subscription);
|
||||
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
@@ -534,6 +676,10 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _userService.DidNotReceive().DisablePremiumAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
|
||||
await _userService.Received(1).UpdatePremiumExpirationAsync(userId, currentPeriodEnd);
|
||||
await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
||||
await _stripeFacade.DidNotReceive()
|
||||
.CancelSubscription(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
|
||||
await _stripeFacade.DidNotReceive()
|
||||
.ListInvoices(Arg.Any<InvoiceListOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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<AcceptOrgUserCommand> sutProvider,
|
||||
User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails)
|
||||
{
|
||||
SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IAutomaticUserConfirmationPolicyEnforcementValidator>()
|
||||
.IsCompliantAsync(Arg.Any<AutomaticUserConfirmationPolicyEnforcementRequest>())
|
||||
.Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user)));
|
||||
|
||||
await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService);
|
||||
|
||||
await sutProvider.GetDependency<IPushAutoConfirmNotificationCommand>()
|
||||
.Received(1)
|
||||
.PushAsync(user.Id, orgUser.OrganizationId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task AcceptOrgUser_WithAutoConfirmFeatureFlagDisabled_DoesNotSendPushNotification(
|
||||
SutProvider<AcceptOrgUserCommand> sutProvider,
|
||||
User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails)
|
||||
{
|
||||
SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
|
||||
.Returns(false);
|
||||
|
||||
await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService);
|
||||
|
||||
await sutProvider.GetDependency<IPushAutoConfirmNotificationCommand>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.PushAsync(Arg.Any<Guid>(), Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
|
||||
private void SetupCommonAcceptOrgUserByTokenMocks(SutProvider<AcceptOrgUserCommand> sutProvider, User user, OrganizationUser orgUser)
|
||||
{
|
||||
sutProvider.GetDependency<IGlobalSettings>().OrganizationInviteExpirationHours.Returns(24);
|
||||
|
||||
@@ -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<PushAutoConfirmNotificationCommand> sutProvider,
|
||||
Guid userId,
|
||||
Guid organizationId,
|
||||
OrganizationUser orgUser,
|
||||
List<OrganizationUserUserDetails> admins)
|
||||
{
|
||||
foreach (var admin in admins)
|
||||
{
|
||||
admin.UserId = Guid.NewGuid();
|
||||
}
|
||||
|
||||
orgUser.Id = Guid.NewGuid();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organizationId, userId)
|
||||
.Returns(orgUser);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin)
|
||||
.Returns(admins);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom)
|
||||
.Returns(new List<OrganizationUserUserDetails>());
|
||||
|
||||
await sutProvider.Sut.PushAsync(userId, organizationId);
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.Received(admins.Count)
|
||||
.PushAsync(Arg.Is<PushNotification<AutoConfirmPushNotification>>(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<PushAutoConfirmNotificationCommand> sutProvider,
|
||||
Guid userId,
|
||||
Guid organizationId,
|
||||
OrganizationUser orgUser,
|
||||
List<OrganizationUserUserDetails> customUsers)
|
||||
{
|
||||
foreach (var customUser in customUsers)
|
||||
{
|
||||
customUser.UserId = Guid.NewGuid();
|
||||
customUser.Permissions = "{\"manageUsers\":true}";
|
||||
}
|
||||
|
||||
orgUser.Id = Guid.NewGuid();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organizationId, userId)
|
||||
.Returns(orgUser);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin)
|
||||
.Returns(new List<OrganizationUserUserDetails>());
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom)
|
||||
.Returns(customUsers);
|
||||
|
||||
await sutProvider.Sut.PushAsync(userId, organizationId);
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.Received(customUsers.Count)
|
||||
.PushAsync(Arg.Is<PushNotification<AutoConfirmPushNotification>>(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<PushAutoConfirmNotificationCommand> sutProvider,
|
||||
Guid userId,
|
||||
Guid organizationId,
|
||||
OrganizationUser orgUser,
|
||||
List<OrganizationUserUserDetails> customUsers)
|
||||
{
|
||||
foreach (var customUser in customUsers)
|
||||
{
|
||||
customUser.UserId = Guid.NewGuid();
|
||||
customUser.Permissions = "{\"manageUsers\":false}";
|
||||
}
|
||||
|
||||
orgUser.Id = Guid.NewGuid();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organizationId, userId)
|
||||
.Returns(orgUser);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin)
|
||||
.Returns(new List<OrganizationUserUserDetails>());
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom)
|
||||
.Returns(customUsers);
|
||||
|
||||
await sutProvider.Sut.PushAsync(userId, organizationId);
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.PushAsync(Arg.Any<PushNotification<AutoConfirmPushNotification>>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PushAsync_SendsToAdminsAndCustomUsersWithManageUsers(
|
||||
SutProvider<PushAutoConfirmNotificationCommand> sutProvider,
|
||||
Guid userId,
|
||||
Guid organizationId,
|
||||
OrganizationUser orgUser,
|
||||
List<OrganizationUserUserDetails> admins,
|
||||
List<OrganizationUserUserDetails> customUsersWithPermission,
|
||||
List<OrganizationUserUserDetails> 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<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organizationId, userId)
|
||||
.Returns(orgUser);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin)
|
||||
.Returns(admins);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom)
|
||||
.Returns(allCustomUsers);
|
||||
|
||||
await sutProvider.Sut.PushAsync(userId, organizationId);
|
||||
|
||||
var expectedNotificationCount = admins.Count + customUsersWithPermission.Count;
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.Received(expectedNotificationCount)
|
||||
.PushAsync(Arg.Is<PushNotification<AutoConfirmPushNotification>>(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<PushAutoConfirmNotificationCommand> sutProvider,
|
||||
Guid userId,
|
||||
Guid organizationId,
|
||||
OrganizationUser orgUser,
|
||||
List<OrganizationUserUserDetails> admins)
|
||||
{
|
||||
admins[0].UserId = Guid.NewGuid();
|
||||
admins[1].UserId = null;
|
||||
admins[2].UserId = Guid.NewGuid();
|
||||
|
||||
orgUser.Id = Guid.NewGuid();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organizationId, userId)
|
||||
.Returns(orgUser);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin)
|
||||
.Returns(admins);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom)
|
||||
.Returns(new List<OrganizationUserUserDetails>());
|
||||
|
||||
await sutProvider.Sut.PushAsync(userId, organizationId);
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.Received(2)
|
||||
.PushAsync(Arg.Is<PushNotification<AutoConfirmPushNotification>>(pn =>
|
||||
pn.Type == PushType.AutoConfirm));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PushAsync_DeduplicatesUserIds(
|
||||
SutProvider<PushAutoConfirmNotificationCommand> 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<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organizationId, userId)
|
||||
.Returns(orgUser);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin)
|
||||
.Returns(new List<OrganizationUserUserDetails> { admin1, admin2 });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom)
|
||||
.Returns(new List<OrganizationUserUserDetails> { customUser });
|
||||
|
||||
await sutProvider.Sut.PushAsync(userId, organizationId);
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.Received(1)
|
||||
.PushAsync(Arg.Is<PushNotification<AutoConfirmPushNotification>>(pn =>
|
||||
pn.TargetId == duplicateUserId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PushAsync_OrganizationUserNotFound_ThrowsException(
|
||||
SutProvider<PushAutoConfirmNotificationCommand> sutProvider,
|
||||
Guid userId,
|
||||
Guid organizationId)
|
||||
{
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organizationId, userId)
|
||||
.Returns((OrganizationUser)null);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<Exception>(() =>
|
||||
sutProvider.Sut.PushAsync(userId, organizationId));
|
||||
|
||||
Assert.Equal("Organization user not found", exception.Message);
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.PushAsync(Arg.Any<PushNotification<AutoConfirmPushNotification>>());
|
||||
}
|
||||
}
|
||||
@@ -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<AutomaticUserConfirmationOrganizationPolicyComplianceValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var orgUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = organizationId,
|
||||
UserId = userId,
|
||||
Status = OrganizationUserStatusType.Confirmed
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns([orgUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.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<AutomaticUserConfirmationOrganizationPolicyComplianceValidator> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns([orgUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([otherOrgUser]);
|
||||
|
||||
var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.IsType<UserNotCompliantWithSingleOrganization>(result.AsError);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task IsOrganizationCompliantAsync_ProviderUsersExist_ReturnsProviderExistsInOrganization(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
SutProvider<AutomaticUserConfirmationOrganizationPolicyComplianceValidator> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns([orgUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([providerUser]);
|
||||
|
||||
var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.IsType<ProviderExistsInOrganization>(result.AsError);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task IsOrganizationCompliantAsync_InvitedUsersExcluded_FromSingleOrgCheck(
|
||||
Guid organizationId,
|
||||
SutProvider<AutomaticUserConfirmationOrganizationPolicyComplianceValidator> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns([invitedUser]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.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<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetManyByManyUsersAsync(Arg.Is<IEnumerable<Guid>>(ids => !ids.Any()));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task IsOrganizationCompliantAsync_InvitedUserWithUserId_ExcludedFromSingleOrgCheck(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
SutProvider<AutomaticUserConfirmationOrganizationPolicyComplianceValidator> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns([invitedUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.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<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetManyByManyUsersAsync(Arg.Is<IEnumerable<Guid>>(ids => !ids.Any()));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task IsOrganizationCompliantAsync_UserInAnotherOrgWithInvitedStatus_ReturnsValid(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
SutProvider<AutomaticUserConfirmationOrganizationPolicyComplianceValidator> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns([orgUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([otherOrgUser]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.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<AutomaticUserConfirmationOrganizationPolicyComplianceValidator> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns([orgUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([otherOrgUser]);
|
||||
|
||||
var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.IsType<UserNotCompliantWithSingleOrganization>(result.AsError);
|
||||
|
||||
// Provider check should not be called since single org check failed first
|
||||
await sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.DidNotReceive()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task IsOrganizationCompliantAsync_MixedUsers_OnlyNonInvitedChecked(
|
||||
Guid organizationId,
|
||||
Guid confirmedUserId,
|
||||
Guid acceptedUserId,
|
||||
SutProvider<AutomaticUserConfirmationOrganizationPolicyComplianceValidator> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns([invitedUser, confirmedUser, acceptedUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.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<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetManyByManyUsersAsync(Arg.Is<IEnumerable<Guid>>(ids =>
|
||||
ids.Count() == 2 &&
|
||||
ids.Contains(confirmedUserId) &&
|
||||
ids.Contains(acceptedUserId)));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task IsOrganizationCompliantAsync_NoOrganizationUsers_ReturnsValid(
|
||||
Guid organizationId,
|
||||
SutProvider<AutomaticUserConfirmationOrganizationPolicyComplianceValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.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<AutomaticUserConfirmationOrganizationPolicyComplianceValidator> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns([orgUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([sameOrgUser]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.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<AutomaticUserConfirmationOrganizationPolicyComplianceValidator> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns([confirmedUser, invitedUserWithNullId, acceptedUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.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<IProviderUserRepository>()
|
||||
.Received(1)
|
||||
.GetManyByManyUsersAsync(Arg.Is<IEnumerable<Guid>>(ids =>
|
||||
ids.Count() == 2 &&
|
||||
ids.Contains(userId1) &&
|
||||
ids.Contains(userId2)));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task IsOrganizationCompliantAsync_RevokedUserInAnotherOrg_ReturnsUserNotCompliant(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
SutProvider<AutomaticUserConfirmationOrganizationPolicyComplianceValidator> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns([revokedUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([otherOrgUser]);
|
||||
|
||||
var request = new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organizationId);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.IsOrganizationCompliantAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.IsType<UserNotCompliantWithSingleOrganization>(result.AsError);
|
||||
}
|
||||
}
|
||||
@@ -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<AutomaticUserConfirmationPolicyEventHandler> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([orgUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([otherOrgUser]);
|
||||
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
|
||||
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>())
|
||||
.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<AutomaticUserConfirmationPolicyEventHandler> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([orgUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([otherOrgUser]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.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<AutomaticUserConfirmationPolicyEventHandler> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([orgUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([providerUser]);
|
||||
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
|
||||
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>())
|
||||
.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<AutomaticUserConfirmationPolicyEventHandler> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([orgUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
|
||||
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>())
|
||||
.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<IOrganizationUserRepository>()
|
||||
await sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
|
||||
.DidNotReceive()
|
||||
.GetManyDetailsByOrganizationAsync(Arg.Any<Guid>());
|
||||
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -227,212 +117,31 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
await sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
|
||||
.DidNotReceive()
|
||||
.GetManyDetailsByOrganizationAsync(Arg.Any<Guid>());
|
||||
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
Guid nonCompliantOwnerId,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([ownerUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.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<AutomaticUserConfirmationPolicyEventHandler> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([invitedUser]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
|
||||
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>())
|
||||
.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<AutomaticUserConfirmationPolicyEventHandler> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([invitedUser, confirmedUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
await sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
|
||||
.Received(1)
|
||||
.GetManyByManyUsersAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 1 && ids.First() == confirmedUserId));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_RevokedUsersIncluded_InComplianceCheck(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> 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<IOrganizationUserRepository>();
|
||||
|
||||
orgUserRepository
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([revokedUser]);
|
||||
|
||||
orgUserRepository.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([additionalOrgUser]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.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<AutomaticUserConfirmationPolicyEventHandler> 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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([acceptedUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.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<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>(
|
||||
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<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([]);
|
||||
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
|
||||
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>())
|
||||
.Returns(Valid(request));
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null);
|
||||
|
||||
@@ -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<BlockClaimedDomainAccountCreationPolicyValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationHasVerifiedDomainsQuery>()
|
||||
.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)
|
||||
.Returns(false);
|
||||
@@ -41,10 +36,6 @@ public class BlockClaimedDomainAccountCreationPolicyValidatorTests
|
||||
SutProvider<BlockClaimedDomainAccountCreationPolicyValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationHasVerifiedDomainsQuery>()
|
||||
.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)
|
||||
.Returns(true);
|
||||
@@ -61,11 +52,6 @@ public class BlockClaimedDomainAccountCreationPolicyValidatorTests
|
||||
[PolicyUpdate(PolicyType.BlockClaimedDomainAccountCreation, false)] PolicyUpdate policyUpdate,
|
||||
SutProvider<BlockClaimedDomainAccountCreationPolicyValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
@@ -82,10 +68,6 @@ public class BlockClaimedDomainAccountCreationPolicyValidatorTests
|
||||
SutProvider<BlockClaimedDomainAccountCreationPolicyValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationHasVerifiedDomainsQuery>()
|
||||
.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)
|
||||
.Returns(false);
|
||||
@@ -105,10 +87,6 @@ public class BlockClaimedDomainAccountCreationPolicyValidatorTests
|
||||
SutProvider<BlockClaimedDomainAccountCreationPolicyValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationHasVerifiedDomainsQuery>()
|
||||
.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)
|
||||
.Returns(true);
|
||||
@@ -128,10 +106,6 @@ public class BlockClaimedDomainAccountCreationPolicyValidatorTests
|
||||
SutProvider<BlockClaimedDomainAccountCreationPolicyValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
|
||||
|
||||
// Act
|
||||
@@ -144,31 +118,11 @@ public class BlockClaimedDomainAccountCreationPolicyValidatorTests
|
||||
.HasVerifiedDomainsAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_FeatureFlagDisabled_ReturnsError(
|
||||
[PolicyUpdate(PolicyType.BlockClaimedDomainAccountCreation, true)] PolicyUpdate policyUpdate,
|
||||
SutProvider<BlockClaimedDomainAccountCreationPolicyValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.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<IOrganizationHasVerifiedDomainsQuery>()
|
||||
.DidNotReceive()
|
||||
.HasVerifiedDomainsAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[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();
|
||||
|
||||
@@ -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<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>(), organization.Id)
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user)
|
||||
.Returns(IdentityResult.Success);
|
||||
@@ -134,6 +139,12 @@ public class RegisterUserCommandTests
|
||||
SutProvider<RegisterUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = $"test+{Guid.NewGuid()}@example.com";
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>(), organization.Id)
|
||||
.Returns(false);
|
||||
|
||||
var expectedError = new IdentityError();
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user)
|
||||
@@ -161,9 +172,14 @@ public class RegisterUserCommandTests
|
||||
SutProvider<RegisterUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = $"test+{Guid.NewGuid()}@example.com";
|
||||
organization.PlanType = planType;
|
||||
organization.Name = "Enterprise Org";
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>(), organization.Id)
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user)
|
||||
.Returns(IdentityResult.Success);
|
||||
@@ -192,6 +208,12 @@ public class RegisterUserCommandTests
|
||||
SutProvider<RegisterUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = $"test+{Guid.NewGuid()}@example.com";
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>(), organization.Id)
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user)
|
||||
.Returns(IdentityResult.Success);
|
||||
@@ -220,8 +242,13 @@ public class RegisterUserCommandTests
|
||||
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = $"test+{Guid.NewGuid()}@example.com";
|
||||
user.ReferenceData = null;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.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<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>(), Arg.Any<Guid?>())
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.DisableUserRegistration.Returns(false);
|
||||
|
||||
@@ -350,6 +383,12 @@ public class RegisterUserCommandTests
|
||||
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid? orgUserId)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = $"test+{Guid.NewGuid()}@example.com";
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.DisableUserRegistration.Returns(true);
|
||||
|
||||
@@ -388,6 +427,12 @@ public class RegisterUserCommandTests
|
||||
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid? orgUserId)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = $"test+{Guid.NewGuid()}@example.com";
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.DisableUserRegistration.Returns(false);
|
||||
|
||||
@@ -457,10 +502,6 @@ public class RegisterUserCommandTests
|
||||
.GetByIdAsync(orgUserId)
|
||||
.Returns(orgUser);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.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<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com", orgUser.OrganizationId)
|
||||
@@ -504,10 +545,6 @@ public class RegisterUserCommandTests
|
||||
.GetByIdAsync(orgUserId)
|
||||
.Returns(orgUser);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
// Mock the new overload - it should return false (domain is NOT blocked by OTHER orgs)
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("company-domain.com", orgUser.OrganizationId)
|
||||
@@ -541,6 +578,10 @@ public class RegisterUserCommandTests
|
||||
orgUser.Email = user.Email;
|
||||
orgUser.Id = orgUserId;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>(), Arg.Any<Guid?>())
|
||||
.Returns(false);
|
||||
|
||||
var orgInviteTokenable = new OrgUserInviteTokenable(orgUser);
|
||||
|
||||
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>()
|
||||
@@ -644,6 +685,12 @@ public class RegisterUserCommandTests
|
||||
public async Task RegisterUserViaEmailVerificationToken_DisabledOpenRegistration_ThrowsBadRequestException(SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, string emailVerificationToken)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = $"test+{Guid.NewGuid()}@example.com";
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.DisableUserRegistration = true;
|
||||
|
||||
@@ -721,6 +768,12 @@ public class RegisterUserCommandTests
|
||||
string masterPasswordHash, string orgSponsoredFreeFamilyPlanInviteToken)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = $"test+{Guid.NewGuid()}@example.com";
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.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<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.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<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.DisableUserRegistration = true;
|
||||
|
||||
@@ -975,10 +1040,6 @@ public class RegisterUserCommandTests
|
||||
// Arrange
|
||||
user.Email = "user@blocked-domain.com";
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com")
|
||||
.Returns(true);
|
||||
@@ -1002,10 +1063,6 @@ public class RegisterUserCommandTests
|
||||
// Arrange
|
||||
user.Email = "user@allowed-domain.com";
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("allowed-domain.com")
|
||||
.Returns(false);
|
||||
@@ -1038,9 +1095,14 @@ public class RegisterUserCommandTests
|
||||
SutProvider<RegisterUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = $"test+{Guid.NewGuid()}@example.com";
|
||||
organization.PlanType = planType;
|
||||
organization.Name = "Family Org";
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>(), organization.Id)
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user)
|
||||
.Returns(IdentityResult.Success);
|
||||
@@ -1071,10 +1133,6 @@ public class RegisterUserCommandTests
|
||||
// Arrange
|
||||
user.Email = "user@blocked-domain.com";
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com")
|
||||
.Returns(true);
|
||||
@@ -1102,10 +1160,6 @@ public class RegisterUserCommandTests
|
||||
// Arrange
|
||||
user.Email = "user@blocked-domain.com";
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com")
|
||||
.Returns(true);
|
||||
@@ -1131,10 +1185,6 @@ public class RegisterUserCommandTests
|
||||
emergencyAccess.Email = user.Email;
|
||||
emergencyAccess.Id = acceptEmergencyAccessId;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com")
|
||||
.Returns(true);
|
||||
@@ -1183,10 +1233,6 @@ public class RegisterUserCommandTests
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.OrganizationInviteExpirationHours.Returns(120); // 5 days
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com")
|
||||
.Returns(true);
|
||||
@@ -1213,10 +1259,6 @@ public class RegisterUserCommandTests
|
||||
// Arrange
|
||||
user.Email = "invalid-email-format";
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.RegisterUser(user));
|
||||
@@ -1232,10 +1274,6 @@ public class RegisterUserCommandTests
|
||||
// Arrange
|
||||
user.Email = "invalid-email-format";
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>()
|
||||
.TryUnprotect(emailVerificationToken, out Arg.Any<RegistrationEmailVerificationTokenable>())
|
||||
.Returns(callInfo =>
|
||||
@@ -1261,9 +1299,14 @@ public class RegisterUserCommandTests
|
||||
SutProvider<RegisterUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = $"test+{Guid.NewGuid()}@example.com";
|
||||
user.ReferenceData = null;
|
||||
orgUser.Email = user.Email;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>(), Arg.Any<Guid?>())
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user, masterPasswordHash)
|
||||
.Returns(IdentityResult.Success);
|
||||
@@ -1310,11 +1353,16 @@ public class RegisterUserCommandTests
|
||||
SutProvider<RegisterUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = $"test+{Guid.NewGuid()}@example.com";
|
||||
Organization organization = new Organization
|
||||
{
|
||||
Name = null
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>(), Arg.Any<Guid?>())
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user)
|
||||
.Returns(IdentityResult.Success);
|
||||
@@ -1348,10 +1396,15 @@ public class RegisterUserCommandTests
|
||||
SutProvider<RegisterUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = $"test+{Guid.NewGuid()}@example.com";
|
||||
user.ReferenceData = null;
|
||||
orgUser.Email = user.Email;
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>(), Arg.Any<Guid?>())
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user, masterPasswordHash)
|
||||
.Returns(IdentityResult.Success);
|
||||
@@ -1406,10 +1459,6 @@ public class RegisterUserCommandTests
|
||||
// Arrange
|
||||
user.Email = "user@blocked-domain.com";
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com", organization.Id)
|
||||
.Returns(true);
|
||||
@@ -1429,10 +1478,6 @@ public class RegisterUserCommandTests
|
||||
// Arrange
|
||||
user.Email = "user@company-domain.com";
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
// Domain is claimed by THIS organization, so it should be allowed
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("company-domain.com", organization.Id)
|
||||
@@ -1461,10 +1506,6 @@ public class RegisterUserCommandTests
|
||||
// Arrange
|
||||
user.Email = "user@unclaimed-domain.com";
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("unclaimed-domain.com", organization.Id)
|
||||
.Returns(false); // Domain is not claimed by any org
|
||||
|
||||
@@ -59,9 +59,11 @@ public class SendVerificationEmailForRegistrationCommandTests
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SendVerificationEmailForRegistrationCommand_WhenFromMarketingIsPremium_SendsEmailWithMarketingParameterAndReturnsNull(SutProvider<SendVerificationEmailForRegistrationCommand> sutProvider,
|
||||
string email, string name, bool receiveMarketingEmails)
|
||||
string name, bool receiveMarketingEmails)
|
||||
{
|
||||
// Arrange
|
||||
var email = $"test+{Guid.NewGuid()}@example.com";
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByEmailAsync(email)
|
||||
.ReturnsNull();
|
||||
@@ -167,9 +169,15 @@ public class SendVerificationEmailForRegistrationCommandTests
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SendVerificationEmailForRegistrationCommand_WhenOpenRegistrationDisabled_ThrowsBadRequestException(SutProvider<SendVerificationEmailForRegistrationCommand> sutProvider,
|
||||
string email, string name, bool receiveMarketingEmails)
|
||||
string name, bool receiveMarketingEmails)
|
||||
{
|
||||
// Arrange
|
||||
var email = $"test+{Guid.NewGuid()}@example.com";
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<GlobalSettings>()
|
||||
.DisableUserRegistration = true;
|
||||
|
||||
@@ -235,10 +243,6 @@ public class SendVerificationEmailForRegistrationCommandTests
|
||||
sutProvider.GetDependency<GlobalSettings>()
|
||||
.DisableUserRegistration = false;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blockedcompany.com")
|
||||
.Returns(true);
|
||||
@@ -266,10 +270,6 @@ public class SendVerificationEmailForRegistrationCommandTests
|
||||
sutProvider.GetDependency<GlobalSettings>()
|
||||
.DisableUserRegistration = false;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("allowedcompany.com")
|
||||
.Returns(false);
|
||||
@@ -298,10 +298,6 @@ public class SendVerificationEmailForRegistrationCommandTests
|
||||
sutProvider.GetDependency<GlobalSettings>()
|
||||
.DisableUserRegistration = false;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)
|
||||
.Returns(true);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static void SetupWebAuthnProviderWithPending(User user, int credentialCount)
|
||||
{
|
||||
var providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
||||
var metadata = new Dictionary<string, object>();
|
||||
|
||||
// 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<CompleteTwoFactorWebAuthnRegistrationCommand> 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<IGlobalSettings>().WebAuthn = maximumAllowedCredentialsGlobalSetting;
|
||||
|
||||
user.Premium = hasPremium;
|
||||
user.Id = Guid.NewGuid();
|
||||
user.Email = "test@example.com";
|
||||
|
||||
sutProvider.GetDependency<IHasPremiumAccessQuery>().HasPremiumAccessAsync(user.Id).Returns(hasPremium);
|
||||
|
||||
SetupWebAuthnProviderWithPending(user,
|
||||
credentialCount: hasPremium
|
||||
? maximumAllowedCredentialsGlobalSetting.PremiumMaximumAllowedCredentials - 1
|
||||
: maximumAllowedCredentialsGlobalSetting.NonPremiumMaximumAllowedCredentials - 1);
|
||||
|
||||
var mockFido2 = sutProvider.GetDependency<IFido2>();
|
||||
mockFido2.MakeNewCredentialAsync(
|
||||
Arg.Any<AuthenticatorAttestationRawResponse>(),
|
||||
Arg.Any<CredentialCreateOptions>(),
|
||||
Arg.Any<IsCredentialIdUniqueToUserAsyncDelegate>())
|
||||
.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<IUserService>().Received(1)
|
||||
.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task CompleteWebAuthRegistrationAsync_ExceedsLimit_ThrowsBadRequestException(bool hasPremium,
|
||||
SutProvider<CompleteTwoFactorWebAuthnRegistrationCommand> 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<IGlobalSettings>().WebAuthn = maximumAllowedCredentialsGlobalSetting;
|
||||
|
||||
user.Premium = hasPremium;
|
||||
sutProvider.GetDependency<IHasPremiumAccessQuery>().HasPremiumAccessAsync(user.Id).Returns(hasPremium);
|
||||
|
||||
|
||||
SetupWebAuthnProviderWithPending(user,
|
||||
credentialCount: hasPremium
|
||||
? maximumAllowedCredentialsGlobalSetting.PremiumMaximumAllowedCredentials
|
||||
: maximumAllowedCredentialsGlobalSetting.NonPremiumMaximumAllowedCredentials);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.CompleteTwoFactorWebAuthnRegistrationAsync(user, 11, "NewKey", deviceResponse));
|
||||
|
||||
Assert.Equal("Maximum allowed WebAuthn credential count exceeded.", exception.Message);
|
||||
}
|
||||
}
|
||||
@@ -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<TwoFactorProviderType, TwoFactorProvider>();
|
||||
var metadata = new Dictionary<string, object>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAsync_KeyExistsWithMultipleKeys_RemovesKeyAndReturnsTrue(
|
||||
SutProvider<DeleteTwoFactorWebAuthnCredentialCommand> 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<IUserService>().Received(1)
|
||||
.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the requested key does not exist, the command should return false
|
||||
/// and not call UserService.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAsync_KeyDoesNotExist_ReturnsFalse(
|
||||
SutProvider<DeleteTwoFactorWebAuthnCredentialCommand> 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<IUserService>().DidNotReceive()
|
||||
.UpdateTwoFactorProviderAsync(Arg.Any<User>(), Arg.Any<TwoFactorProviderType>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Users must retain at least one WebAuthn credential. When only one key remains,
|
||||
/// deletion should be rejected to prevent lockout.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAsync_OnlyOneKeyRemaining_ReturnsFalse(
|
||||
SutProvider<DeleteTwoFactorWebAuthnCredentialCommand> 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<IUserService>().DidNotReceive()
|
||||
.UpdateTwoFactorProviderAsync(Arg.Any<User>(), Arg.Any<TwoFactorProviderType>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the user has no two-factor providers configured, deletion should return false.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAsync_NoProviders_ReturnsFalse(
|
||||
SutProvider<DeleteTwoFactorWebAuthnCredentialCommand> 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<IUserService>().DidNotReceive()
|
||||
.UpdateTwoFactorProviderAsync(Arg.Any<User>(), Arg.Any<TwoFactorProviderType>());
|
||||
}
|
||||
}
|
||||
@@ -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<TwoFactorProviderType, TwoFactorProvider>();
|
||||
var metadata = new Dictionary<string, object>();
|
||||
|
||||
// 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<StartTwoFactorWebAuthnRegistrationCommand> 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<IGlobalSettings>().WebAuthn = maximumAllowedCredentialsGlobalSetting;
|
||||
|
||||
user.Premium = hasPremium;
|
||||
user.Id = Guid.NewGuid();
|
||||
user.Email = "test@example.com";
|
||||
|
||||
sutProvider.GetDependency<IHasPremiumAccessQuery>().HasPremiumAccessAsync(user.Id).Returns(hasPremium);
|
||||
|
||||
SetupWebAuthnProvider(user,
|
||||
credentialCount: hasPremium
|
||||
? maximumAllowedCredentialsGlobalSetting.PremiumMaximumAllowedCredentials - 1
|
||||
: maximumAllowedCredentialsGlobalSetting.NonPremiumMaximumAllowedCredentials - 1);
|
||||
|
||||
var mockFido2 = sutProvider.GetDependency<IFido2>();
|
||||
mockFido2.RequestNewCredential(
|
||||
Arg.Any<Fido2User>(),
|
||||
Arg.Any<List<PublicKeyCredentialDescriptor>>(),
|
||||
Arg.Any<AuthenticatorSelection>(),
|
||||
Arg.Any<AttestationConveyancePreference>())
|
||||
.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<IUserService>().Received(1)
|
||||
.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.WebAuthn, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "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.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task StartWebAuthnRegistrationAsync_ExceedsLimit_ThrowsBadRequestException(
|
||||
bool hasPremium, SutProvider<StartTwoFactorWebAuthnRegistrationCommand> 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<IGlobalSettings>().WebAuthn = maximumAllowedCredentialsGlobalSetting;
|
||||
|
||||
user.Premium = hasPremium;
|
||||
user.Id = Guid.NewGuid();
|
||||
user.Email = "test@example.com";
|
||||
|
||||
sutProvider.GetDependency<IHasPremiumAccessQuery>().HasPremiumAccessAsync(user.Id).Returns(hasPremium);
|
||||
|
||||
SetupWebAuthnProvider(user,
|
||||
credentialCount: hasPremium
|
||||
? maximumAllowedCredentialsGlobalSetting.PremiumMaximumAllowedCredentials
|
||||
: maximumAllowedCredentialsGlobalSetting.NonPremiumMaximumAllowedCredentials);
|
||||
|
||||
var mockFido2 = sutProvider.GetDependency<IFido2>();
|
||||
mockFido2.RequestNewCredential(
|
||||
Arg.Any<Fido2User>(),
|
||||
Arg.Any<List<PublicKeyCredentialDescriptor>>(),
|
||||
Arg.Any<AuthenticatorSelection>(),
|
||||
Arg.Any<AttestationConveyancePreference>())
|
||||
.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<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.StartTwoFactorWebAuthnRegistrationAsync(user));
|
||||
Assert.Equal("Maximum allowed WebAuthn credential count exceeded.", exception.Message);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<StripeSubscription>();
|
||||
existingCanceledSubscription.Id = "sub_canceled_123";
|
||||
existingCanceledSubscription.Status = "canceled"; // Terminal status
|
||||
|
||||
var mockCustomer = Substitute.For<StripeCustomer>();
|
||||
mockCustomer.Id = "existing_customer_123";
|
||||
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
|
||||
mockCustomer.Metadata = new Dictionary<string, string>();
|
||||
|
||||
var newSubscription = Substitute.For<StripeSubscription>();
|
||||
newSubscription.Id = "sub_new_123";
|
||||
newSubscription.Status = "active";
|
||||
newSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId).Returns(existingCanceledSubscription);
|
||||
_hasPaymentMethodQuery.Run(Arg.Any<User>()).Returns(true);
|
||||
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
|
||||
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).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<SubscriptionCreateOptions>());
|
||||
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<StripeSubscription>();
|
||||
existingExpiredSubscription.Id = "sub_incomplete_expired_123";
|
||||
existingExpiredSubscription.Status = "incomplete_expired"; // Terminal status
|
||||
|
||||
var mockCustomer = Substitute.For<StripeCustomer>();
|
||||
mockCustomer.Id = "existing_customer_123";
|
||||
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
|
||||
mockCustomer.Metadata = new Dictionary<string, string>();
|
||||
|
||||
var newSubscription = Substitute.For<StripeSubscription>();
|
||||
newSubscription.Id = "sub_new_123";
|
||||
newSubscription.Status = "active";
|
||||
newSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId).Returns(existingExpiredSubscription);
|
||||
_hasPaymentMethodQuery.Run(Arg.Any<User>()).Returns(true);
|
||||
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
|
||||
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).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<SubscriptionCreateOptions>());
|
||||
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<StripeSubscription>();
|
||||
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<SubscriptionCreateOptions>());
|
||||
}
|
||||
|
||||
[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<StripeSubscription>(_ => throw new Stripe.StripeException("Subscription not found"));
|
||||
|
||||
var mockCustomer = Substitute.For<StripeCustomer>();
|
||||
mockCustomer.Id = "existing_customer_123";
|
||||
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
|
||||
mockCustomer.Metadata = new Dictionary<string, string>();
|
||||
|
||||
var newSubscription = Substitute.For<StripeSubscription>();
|
||||
newSubscription.Id = "sub_new_123";
|
||||
newSubscription.Status = "active";
|
||||
newSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
_hasPaymentMethodQuery.Run(Arg.Any<User>()).Returns(true);
|
||||
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
|
||||
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).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<SubscriptionCreateOptions>());
|
||||
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<StripeSubscription>();
|
||||
existingCanceledSubscription.Id = "sub_canceled_123";
|
||||
existingCanceledSubscription.Status = "canceled"; // Terminal status
|
||||
|
||||
var mockCustomer = Substitute.For<StripeCustomer>();
|
||||
mockCustomer.Id = "existing_customer_123";
|
||||
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
|
||||
mockCustomer.Metadata = new Dictionary<string, string>();
|
||||
|
||||
var newSubscription = Substitute.For<StripeSubscription>();
|
||||
newSubscription.Id = "sub_new_123";
|
||||
newSubscription.Status = "active";
|
||||
newSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
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<User>()).Returns(true); // Has old payment method
|
||||
_updatePaymentMethodCommand.Run(Arg.Any<User>(), Arg.Any<TokenizedPaymentMethod>(), Arg.Any<BillingAddress>())
|
||||
.Returns(mockMaskedPaymentMethod);
|
||||
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
|
||||
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).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<SubscriptionCreateOptions>());
|
||||
await _userService.Received(1).SaveUserAsync(user);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<string>(), Arg.Any<SubscriptionGetOptions>());
|
||||
}
|
||||
|
||||
[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<string>(), Arg.Any<SubscriptionGetOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_IncompleteStatus_ReturnsBitwardenSubscriptionWithSuspension()
|
||||
{
|
||||
|
||||
@@ -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<UserService> sutProvider, User user)
|
||||
{
|
||||
// Arrange - Non-premium user with 4 credentials (below limit of 5)
|
||||
SetupWebAuthnProvider(user, credentialCount: 4);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().WebAuthn = new GlobalSettings.WebAuthnSettings
|
||||
{
|
||||
PremiumMaximumAllowedCredentials = 10,
|
||||
NonPremiumMaximumAllowedCredentials = 5
|
||||
};
|
||||
|
||||
user.Premium = hasPremium;
|
||||
user.Id = Guid.NewGuid();
|
||||
user.Email = "test@example.com";
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(user.Id)
|
||||
.Returns(new List<OrganizationUser>());
|
||||
|
||||
var mockFido2 = sutProvider.GetDependency<IFido2>();
|
||||
mockFido2.RequestNewCredential(
|
||||
Arg.Any<Fido2User>(),
|
||||
Arg.Any<List<PublicKeyCredentialDescriptor>>(),
|
||||
Arg.Any<AuthenticatorSelection>(),
|
||||
Arg.Any<AttestationConveyancePreference>())
|
||||
.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<PubKeyCredParam>()
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.StartWebAuthnRegistrationAsync(user);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync(user);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public async Task CompleteWebAuthRegistrationAsync_ExceedsLimit_ThrowsBadRequestException(bool hasPremium,
|
||||
SutProvider<UserService> 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<IGlobalSettings>().WebAuthn = new GlobalSettings.WebAuthnSettings
|
||||
{
|
||||
PremiumMaximumAllowedCredentials = 10,
|
||||
NonPremiumMaximumAllowedCredentials = 5
|
||||
};
|
||||
|
||||
user.Premium = hasPremium;
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(user.Id)
|
||||
.Returns(new List<OrganizationUser>());
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => 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<UserService> sutProvider, User user, AuthenticatorAttestationRawResponse deviceResponse)
|
||||
{
|
||||
// Arrange - User has 4 credentials (below limit of 5)
|
||||
SetupWebAuthnProviderWithPending(user, credentialCount: 4);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().WebAuthn = new GlobalSettings.WebAuthnSettings
|
||||
{
|
||||
PremiumMaximumAllowedCredentials = 10,
|
||||
NonPremiumMaximumAllowedCredentials = 5
|
||||
};
|
||||
|
||||
user.Premium = hasPremium;
|
||||
user.Id = Guid.NewGuid();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(user.Id)
|
||||
.Returns(new List<OrganizationUser>());
|
||||
|
||||
var mockFido2 = sutProvider.GetDependency<IFido2>();
|
||||
mockFido2.MakeNewCredentialAsync(
|
||||
Arg.Any<AuthenticatorAttestationRawResponse>(),
|
||||
Arg.Any<CredentialCreateOptions>(),
|
||||
Arg.Any<IsCredentialIdUniqueToUserAsyncDelegate>())
|
||||
.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<IUserRepository>().Received(1).ReplaceAsync(user);
|
||||
}
|
||||
|
||||
private static void SetupWebAuthnProvider(User user, int credentialCount)
|
||||
{
|
||||
var providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
|
||||
var metadata = new Dictionary<string, object>();
|
||||
|
||||
// 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<TwoFactorProviderType, TwoFactorProvider>();
|
||||
var metadata = new Dictionary<string, object>();
|
||||
|
||||
// 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<PubKeyCredParam>()
|
||||
};
|
||||
metadata["pending"] = pendingOptions.ToJson();
|
||||
|
||||
providers[TwoFactorProviderType.WebAuthn] = new TwoFactorProvider
|
||||
{
|
||||
Enabled = true,
|
||||
MetaData = metadata
|
||||
};
|
||||
|
||||
user.SetTwoFactorProviders(providers);
|
||||
}
|
||||
}
|
||||
|
||||
public static class UserServiceSutProviderExtensions
|
||||
|
||||
@@ -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>`.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
@@ -22,17 +22,17 @@ public interface IQuery
|
||||
/// </summary>
|
||||
/// <param name="request">The request object containing parameters for the query operation.</param>
|
||||
/// <returns>The query result data as an object.</returns>
|
||||
object Execute(object request);
|
||||
Task<object> Execute(object request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">The type of request object this query accepts.</typeparam>
|
||||
/// <typeparam name="TResult">The type of data this query returns.</typeparam>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
@@ -43,7 +43,7 @@ public interface IQuery<TRequest, TResult> : IQuery where TRequest : class where
|
||||
/// </summary>
|
||||
/// <param name="request">The request object containing parameters for the query operation.</param>
|
||||
/// <returns>The typed query result data.</returns>
|
||||
TResult Execute(TRequest request);
|
||||
Task<TResult> Execute(TRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request type for this query.
|
||||
@@ -56,5 +56,5 @@ public interface IQuery<TRequest, TResult> : IQuery where TRequest : class where
|
||||
/// </summary>
|
||||
/// <param name="request">The request object to cast and process.</param>
|
||||
/// <returns>The typed result cast to object.</returns>
|
||||
object IQuery.Execute(object request) => Execute((TRequest)request);
|
||||
async Task<object> IQuery.Execute(object request) => await Execute((TRequest)request);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public class EmergencyAccessInviteQuery(
|
||||
public required string Email { get; set; }
|
||||
}
|
||||
|
||||
public IEnumerable<string> Execute(Request request)
|
||||
public Task<IEnumerable<string>> 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);
|
||||
}
|
||||
}
|
||||
|
||||
54
util/Seeder/Queries/UserEmailVerificationQuery.cs
Normal file
54
util/Seeder/Queries/UserEmailVerificationQuery.cs
Normal file
@@ -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<RegistrationEmailVerificationTokenable> dataProtectorTokenizer) : IQuery<UserEmailVerificationQuery.Request, UserEmailVerificationQuery.Response>
|
||||
{
|
||||
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<Response> 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,13 @@ namespace Bit.SeederApi.Controllers;
|
||||
public class QueryController(ILogger<QueryController> logger, IQueryExecutor queryExecutor) : Controller
|
||||
{
|
||||
[HttpPost]
|
||||
public IActionResult Query([FromBody] QueryRequestModel request)
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
@@ -18,5 +18,5 @@ public interface IQueryExecutor
|
||||
/// <returns>The result of the query execution</returns>
|
||||
/// <exception cref="Services.QueryNotFoundException">Thrown when the query is not found</exception>
|
||||
/// <exception cref="Services.QueryExecutionException">Thrown when there's an error executing the query</exception>
|
||||
object Execute(string queryName, JsonElement? arguments);
|
||||
Task<object> Execute(string queryName, JsonElement? arguments);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ public class QueryExecutor(
|
||||
IServiceProvider serviceProvider) : IQueryExecutor
|
||||
{
|
||||
|
||||
public object Execute(string queryName, JsonElement? arguments)
|
||||
public async Task<object> 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;
|
||||
|
||||
Reference in New Issue
Block a user