1
0
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:
Patrick-Pimentel-Bitwarden
2026-02-13 17:08:04 -05:00
committed by GitHub
74 changed files with 4929 additions and 2745 deletions

View File

@@ -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)]

View File

@@ -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;
}

View File

@@ -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")]

View File

@@ -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)}";
}
}

View File

@@ -303,7 +303,8 @@ public class Startup
{
swaggerDoc.Servers =
[
new() {
new()
{
Url = globalSettings.BaseServiceUri.Api,
}
];

View File

@@ -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))

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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) =>

View File

@@ -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;
}

View File

@@ -0,0 +1,6 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
public interface IPushAutoConfirmNotificationCommand
{
Task PushAsync(Guid userId, Guid organizationId);
}

View File

@@ -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,
});
}
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,3 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
public record AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(Guid OrganizationId);

View File

@@ -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.");

View File

@@ -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);
}

View File

@@ -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.")]

View File

@@ -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;
}
}

View File

@@ -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 })
{

View File

@@ -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(

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
{

View File

@@ -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>();
}

View File

@@ -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!);

View File

@@ -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;
}
}
}

View File

@@ -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 =

View File

@@ -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";

View File

@@ -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"
]
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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; }
}

View File

@@ -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>();

View File

@@ -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,
}

View File

@@ -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);

View File

@@ -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)

View File

@@ -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;

View File

@@ -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

View File

@@ -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);

View File

@@ -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]

View File

@@ -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)

View File

@@ -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]

View File

@@ -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);

View File

@@ -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>>());
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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();

View File

@@ -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

View File

@@ -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));

View File

@@ -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);
}
}

View File

@@ -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>());
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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()
{

View File

@@ -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

View File

@@ -4,8 +4,8 @@
/// Base interface for query operations in the seeding system. The base interface should not be used directly, rather use `IQuery&lt;TRequest, TResult&gt;`.
/// </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);
}

View File

@@ -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);
}
}

View 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)
);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;