mirror of
https://github.com/bitwarden/server.git
synced 2025-07-01 16:12:49 -05:00
Merge branch 'refs/heads/main' into km/pm-10600
This commit is contained in:
@ -40,6 +40,36 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
}
|
||||
|
||||
public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats)
|
||||
{
|
||||
var providerId = await CreateProviderAsync(provider, ownerEmail);
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (isConsolidatedBillingEnabled)
|
||||
{
|
||||
await CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats);
|
||||
await CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CreateResellerAsync(Provider provider)
|
||||
{
|
||||
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
|
||||
}
|
||||
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats)
|
||||
{
|
||||
var providerId = await CreateProviderAsync(provider, ownerEmail);
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (isConsolidatedBillingEnabled)
|
||||
{
|
||||
await CreateProviderPlanAsync(providerId, plan, minimumSeats);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Guid> CreateProviderAsync(Provider provider, string ownerEmail)
|
||||
{
|
||||
var owner = await _userRepository.GetByEmailAsync(ownerEmail);
|
||||
if (owner == null)
|
||||
@ -64,27 +94,10 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
Status = ProviderUserStatusType.Confirmed,
|
||||
};
|
||||
|
||||
if (isConsolidatedBillingEnabled)
|
||||
{
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
CreateProviderPlan(provider.Id, PlanType.TeamsMonthly, teamsMinimumSeats),
|
||||
CreateProviderPlan(provider.Id, PlanType.EnterpriseMonthly, enterpriseMinimumSeats)
|
||||
};
|
||||
|
||||
foreach (var providerPlan in providerPlans)
|
||||
{
|
||||
await _providerPlanRepository.CreateAsync(providerPlan);
|
||||
}
|
||||
}
|
||||
|
||||
await _providerUserRepository.CreateAsync(providerUser);
|
||||
await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email);
|
||||
}
|
||||
|
||||
public async Task CreateResellerAsync(Provider provider)
|
||||
{
|
||||
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
|
||||
return provider.Id;
|
||||
}
|
||||
|
||||
private async Task ProviderRepositoryCreateAsync(Provider provider, ProviderStatusType status)
|
||||
@ -95,9 +108,9 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
await _providerRepository.CreateAsync(provider);
|
||||
}
|
||||
|
||||
private ProviderPlan CreateProviderPlan(Guid providerId, PlanType planType, int seatMinimum)
|
||||
private async Task CreateProviderPlanAsync(Guid providerId, PlanType planType, int seatMinimum)
|
||||
{
|
||||
return new ProviderPlan
|
||||
var plan = new ProviderPlan
|
||||
{
|
||||
ProviderId = providerId,
|
||||
PlanType = planType,
|
||||
@ -105,5 +118,6 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
};
|
||||
await _providerPlanRepository.CreateAsync(plan);
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@ -19,23 +20,30 @@ public class CreateProviderCommandTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Type = ProviderType.Msp;
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CreateMspAsync(provider, default, default, default));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Invalid owner.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider<CreateProviderCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Type = ProviderType.Msp;
|
||||
|
||||
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||
userRepository.GetByEmailAsync(user.Email).Returns(user);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
|
||||
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
|
||||
}
|
||||
@ -43,11 +51,52 @@ public class CreateProviderCommandTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateResellerAsync_Success(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Type = ProviderType.Reseller;
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.CreateResellerAsync(provider);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
|
||||
await sutProvider.GetDependency<IProviderService>().DidNotReceiveWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync_Success(
|
||||
Provider provider,
|
||||
User user,
|
||||
PlanType plan,
|
||||
int minimumSeats,
|
||||
SutProvider<CreateProviderCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Type = ProviderType.MultiOrganizationEnterprise;
|
||||
|
||||
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||
userRepository.GetByEmailAsync(user.Email).Returns(user);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, user.Email, plan, minimumSeats);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(provider);
|
||||
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync_UserIdIsInvalid_Throws(
|
||||
Provider provider,
|
||||
SutProvider<CreateProviderCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Type = ProviderType.Msp;
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, default, default, default));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Invalid owner.", exception.Message);
|
||||
}
|
||||
}
|
||||
|
@ -107,9 +107,15 @@ public class ProvidersController : Controller
|
||||
});
|
||||
}
|
||||
|
||||
public IActionResult Create(int teamsMinimumSeats, int enterpriseMinimumSeats, string ownerEmail = null)
|
||||
public IActionResult Create()
|
||||
{
|
||||
return View(new CreateProviderModel
|
||||
return View(new CreateProviderModel());
|
||||
}
|
||||
|
||||
[HttpGet("providers/create/msp")]
|
||||
public IActionResult CreateMsp(int teamsMinimumSeats, int enterpriseMinimumSeats, string ownerEmail = null)
|
||||
{
|
||||
return View(new CreateMspProviderModel
|
||||
{
|
||||
OwnerEmail = ownerEmail,
|
||||
TeamsMonthlySeatMinimum = teamsMinimumSeats,
|
||||
@ -117,10 +123,50 @@ public class ProvidersController : Controller
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("providers/create/reseller")]
|
||||
public IActionResult CreateReseller()
|
||||
{
|
||||
return View(new CreateResellerProviderModel());
|
||||
}
|
||||
|
||||
[HttpGet("providers/create/multi-organization-enterprise")]
|
||||
public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null)
|
||||
{
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
|
||||
{
|
||||
return RedirectToAction("Create");
|
||||
}
|
||||
|
||||
return View(new CreateMultiOrganizationEnterpriseProviderModel
|
||||
{
|
||||
OwnerEmail = ownerEmail,
|
||||
EnterpriseSeatMinimum = enterpriseMinimumSeats
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RequirePermission(Permission.Provider_Create)]
|
||||
public async Task<IActionResult> Create(CreateProviderModel model)
|
||||
public IActionResult Create(CreateProviderModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
|
||||
return model.Type switch
|
||||
{
|
||||
ProviderType.Msp => RedirectToAction("CreateMsp"),
|
||||
ProviderType.Reseller => RedirectToAction("CreateReseller"),
|
||||
ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"),
|
||||
_ => View(model)
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("providers/create/msp")]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RequirePermission(Permission.Provider_Create)]
|
||||
public async Task<IActionResult> CreateMsp(CreateMspProviderModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
@ -128,19 +174,51 @@ public class ProvidersController : Controller
|
||||
}
|
||||
|
||||
var provider = model.ToProvider();
|
||||
switch (provider.Type)
|
||||
|
||||
await _createProviderCommand.CreateMspAsync(
|
||||
provider,
|
||||
model.OwnerEmail,
|
||||
model.TeamsMonthlySeatMinimum,
|
||||
model.EnterpriseMonthlySeatMinimum);
|
||||
|
||||
return RedirectToAction("Edit", new { id = provider.Id });
|
||||
}
|
||||
|
||||
[HttpPost("providers/create/reseller")]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RequirePermission(Permission.Provider_Create)]
|
||||
public async Task<IActionResult> CreateReseller(CreateResellerProviderModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
case ProviderType.Msp:
|
||||
await _createProviderCommand.CreateMspAsync(
|
||||
provider,
|
||||
model.OwnerEmail,
|
||||
model.TeamsMonthlySeatMinimum,
|
||||
model.EnterpriseMonthlySeatMinimum);
|
||||
break;
|
||||
case ProviderType.Reseller:
|
||||
await _createProviderCommand.CreateResellerAsync(provider);
|
||||
break;
|
||||
return View(model);
|
||||
}
|
||||
var provider = model.ToProvider();
|
||||
await _createProviderCommand.CreateResellerAsync(provider);
|
||||
|
||||
return RedirectToAction("Edit", new { id = provider.Id });
|
||||
}
|
||||
|
||||
[HttpPost("providers/create/multi-organization-enterprise")]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RequirePermission(Permission.Provider_Create)]
|
||||
public async Task<IActionResult> CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
var provider = model.ToProvider();
|
||||
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
|
||||
{
|
||||
return RedirectToAction("Create");
|
||||
}
|
||||
await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync(
|
||||
provider,
|
||||
model.OwnerEmail,
|
||||
model.Plan.Value,
|
||||
model.EnterpriseSeatMinimum);
|
||||
|
||||
return RedirectToAction("Edit", new { id = provider.Id });
|
||||
}
|
||||
|
45
src/Admin/AdminConsole/Models/CreateMspProviderModel.cs
Normal file
45
src/Admin/AdminConsole/Models/CreateMspProviderModel.cs
Normal file
@ -0,0 +1,45 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
|
||||
namespace Bit.Admin.AdminConsole.Models;
|
||||
|
||||
public class CreateMspProviderModel : IValidatableObject
|
||||
{
|
||||
[Display(Name = "Owner Email")]
|
||||
public string OwnerEmail { get; set; }
|
||||
|
||||
[Display(Name = "Teams (Monthly) Seat Minimum")]
|
||||
public int TeamsMonthlySeatMinimum { get; set; }
|
||||
|
||||
[Display(Name = "Enterprise (Monthly) Seat Minimum")]
|
||||
public int EnterpriseMonthlySeatMinimum { get; set; }
|
||||
|
||||
public virtual Provider ToProvider()
|
||||
{
|
||||
return new Provider
|
||||
{
|
||||
Type = ProviderType.Msp
|
||||
};
|
||||
}
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(OwnerEmail))
|
||||
{
|
||||
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(OwnerEmail);
|
||||
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
|
||||
}
|
||||
if (TeamsMonthlySeatMinimum < 0)
|
||||
{
|
||||
var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(TeamsMonthlySeatMinimum);
|
||||
yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative.");
|
||||
}
|
||||
if (EnterpriseMonthlySeatMinimum < 0)
|
||||
{
|
||||
var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum);
|
||||
yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative.");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
|
||||
namespace Bit.Admin.AdminConsole.Models;
|
||||
|
||||
public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
|
||||
{
|
||||
[Display(Name = "Owner Email")]
|
||||
public string OwnerEmail { get; set; }
|
||||
|
||||
[Display(Name = "Enterprise Seat Minimum")]
|
||||
public int EnterpriseSeatMinimum { get; set; }
|
||||
|
||||
[Display(Name = "Plan")]
|
||||
[Required]
|
||||
public PlanType? Plan { get; set; }
|
||||
|
||||
public virtual Provider ToProvider()
|
||||
{
|
||||
return new Provider
|
||||
{
|
||||
Type = ProviderType.MultiOrganizationEnterprise
|
||||
};
|
||||
}
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(OwnerEmail))
|
||||
{
|
||||
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(OwnerEmail);
|
||||
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
|
||||
}
|
||||
if (EnterpriseSeatMinimum < 0)
|
||||
{
|
||||
var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(EnterpriseSeatMinimum);
|
||||
yield return new ValidationResult($"The {enterpriseSeatMinimumDisplayName} field can not be negative.");
|
||||
}
|
||||
if (Plan != PlanType.EnterpriseAnnually && Plan != PlanType.EnterpriseMonthly)
|
||||
{
|
||||
var planDisplayName = nameof(Plan).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(Plan);
|
||||
yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly.");
|
||||
}
|
||||
}
|
||||
}
|
@ -1,84 +1,8 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
|
||||
namespace Bit.Admin.AdminConsole.Models;
|
||||
|
||||
public class CreateProviderModel : IValidatableObject
|
||||
public class CreateProviderModel
|
||||
{
|
||||
public CreateProviderModel() { }
|
||||
|
||||
[Display(Name = "Provider Type")]
|
||||
public ProviderType Type { get; set; }
|
||||
|
||||
[Display(Name = "Owner Email")]
|
||||
public string OwnerEmail { get; set; }
|
||||
|
||||
[Display(Name = "Name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[Display(Name = "Business Name")]
|
||||
public string BusinessName { get; set; }
|
||||
|
||||
[Display(Name = "Primary Billing Email")]
|
||||
public string BillingEmail { get; set; }
|
||||
|
||||
[Display(Name = "Teams (Monthly) Seat Minimum")]
|
||||
public int TeamsMonthlySeatMinimum { get; set; }
|
||||
|
||||
[Display(Name = "Enterprise (Monthly) Seat Minimum")]
|
||||
public int EnterpriseMonthlySeatMinimum { get; set; }
|
||||
|
||||
public virtual Provider ToProvider()
|
||||
{
|
||||
return new Provider()
|
||||
{
|
||||
Type = Type,
|
||||
Name = Name,
|
||||
BusinessName = BusinessName,
|
||||
BillingEmail = BillingEmail?.ToLowerInvariant().Trim()
|
||||
};
|
||||
}
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
switch (Type)
|
||||
{
|
||||
case ProviderType.Msp:
|
||||
if (string.IsNullOrWhiteSpace(OwnerEmail))
|
||||
{
|
||||
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(OwnerEmail);
|
||||
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
|
||||
}
|
||||
if (TeamsMonthlySeatMinimum < 0)
|
||||
{
|
||||
var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(TeamsMonthlySeatMinimum);
|
||||
yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative.");
|
||||
}
|
||||
if (EnterpriseMonthlySeatMinimum < 0)
|
||||
{
|
||||
var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum);
|
||||
yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative.");
|
||||
}
|
||||
break;
|
||||
case ProviderType.Reseller:
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
var nameDisplayName = nameof(Name).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Name);
|
||||
yield return new ValidationResult($"The {nameDisplayName} field is required.");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(BusinessName))
|
||||
{
|
||||
var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BusinessName);
|
||||
yield return new ValidationResult($"The {businessNameDisplayName} field is required.");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(BillingEmail))
|
||||
{
|
||||
var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BillingEmail);
|
||||
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
48
src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs
Normal file
48
src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs
Normal file
@ -0,0 +1,48 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
|
||||
namespace Bit.Admin.AdminConsole.Models;
|
||||
|
||||
public class CreateResellerProviderModel : IValidatableObject
|
||||
{
|
||||
[Display(Name = "Name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[Display(Name = "Business Name")]
|
||||
public string BusinessName { get; set; }
|
||||
|
||||
[Display(Name = "Primary Billing Email")]
|
||||
public string BillingEmail { get; set; }
|
||||
|
||||
public virtual Provider ToProvider()
|
||||
{
|
||||
return new Provider
|
||||
{
|
||||
Name = Name,
|
||||
BusinessName = BusinessName,
|
||||
BillingEmail = BillingEmail?.ToLowerInvariant().Trim(),
|
||||
Type = ProviderType.Reseller
|
||||
};
|
||||
}
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
var nameDisplayName = nameof(Name).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Name);
|
||||
yield return new ValidationResult($"The {nameDisplayName} field is required.");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(BusinessName))
|
||||
{
|
||||
var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BusinessName);
|
||||
yield return new ValidationResult($"The {businessNameDisplayName} field is required.");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(BillingEmail))
|
||||
{
|
||||
var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BillingEmail);
|
||||
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
|
||||
}
|
||||
}
|
||||
}
|
@ -1,80 +1,48 @@
|
||||
@using Bit.SharedWeb.Utilities
|
||||
@using Bit.Core.AdminConsole.Enums.Provider
|
||||
@using Bit.Core
|
||||
|
||||
@model CreateProviderModel
|
||||
|
||||
@inject Bit.Core.Services.IFeatureService FeatureService
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Create Provider";
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function toggleProviderTypeInfo(value) {
|
||||
document.querySelectorAll('[id^="info-"]').forEach(el => { el.classList.add('d-none'); });
|
||||
document.getElementById('info-' + value).classList.remove('d-none');
|
||||
}
|
||||
</script>
|
||||
var providerTypes = Enum.GetValues<ProviderType>()
|
||||
.OrderBy(x => x.GetDisplayAttribute().Order)
|
||||
.ToList();
|
||||
|
||||
if (!FeatureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
|
||||
{
|
||||
providerTypes.Remove(ProviderType.MultiOrganizationEnterprise);
|
||||
}
|
||||
}
|
||||
|
||||
<h1>Create Provider</h1>
|
||||
|
||||
<form method="post">
|
||||
<form method="post" asp-action="Create">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="Type" class="h2"></label>
|
||||
@foreach(ProviderType providerType in Enum.GetValues(typeof(ProviderType)))
|
||||
@foreach (var providerType in providerTypes)
|
||||
{
|
||||
var providerTypeValue = (int)providerType;
|
||||
<div class="form-check">
|
||||
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input", onclick=$"toggleProviderTypeInfo({providerTypeValue})" })
|
||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" })
|
||||
<br/>
|
||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted ml-3 align-top", @for = $"providerType-{providerTypeValue}" })
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div id="@($"info-{(int)ProviderType.Msp}")" class="form-group @(Model.Type != ProviderType.Msp ? "d-none" : string.Empty)">
|
||||
<h2>MSP Info</h2>
|
||||
<div class="form-group">
|
||||
<label asp-for="OwnerEmail"></label>
|
||||
<input type="text" class="form-control" asp-for="OwnerEmail">
|
||||
</div>
|
||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="TeamsMonthlySeatMinimum"></label>
|
||||
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
|
||||
<div class="form-group">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="form-check">
|
||||
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input" })
|
||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
|
||||
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted align-top", @for = $"providerType-{providerTypeValue}" })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div id="@($"info-{(int)ProviderType.Reseller}")" class="form-group @(Model.Type != ProviderType.Reseller ? "d-none" : string.Empty)">
|
||||
<h2>Reseller Info</h2>
|
||||
<div class="form-group">
|
||||
<label asp-for="Name"></label>
|
||||
<input type="text" class="form-control" asp-for="Name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="BusinessName"></label>
|
||||
<input type="text" class="form-control" asp-for="BusinessName">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="BillingEmail"></label>
|
||||
<input type="text" class="form-control" asp-for="BillingEmail">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
||||
<button type="submit" class="btn btn-primary mb-2">Next</button>
|
||||
</form>
|
||||
|
39
src/Admin/AdminConsole/Views/Providers/CreateMsp.cshtml
Normal file
39
src/Admin/AdminConsole/Views/Providers/CreateMsp.cshtml
Normal file
@ -0,0 +1,39 @@
|
||||
@using Bit.Core.AdminConsole.Enums.Provider
|
||||
@using Bit.Core
|
||||
|
||||
@model CreateMspProviderModel
|
||||
|
||||
@inject Bit.Core.Services.IFeatureService FeatureService
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Create Managed Service Provider";
|
||||
}
|
||||
|
||||
<h1>Create Managed Service Provider</h1>
|
||||
<div>
|
||||
<form class="form-group" method="post" asp-action="CreateMsp">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="OwnerEmail"></label>
|
||||
<input type="text" class="form-control" asp-for="OwnerEmail">
|
||||
</div>
|
||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="TeamsMonthlySeatMinimum"></label>
|
||||
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
|
||||
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
||||
</form>
|
||||
</div>
|
@ -0,0 +1,43 @@
|
||||
@using Bit.Core.Billing.Enums
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
|
||||
@model CreateMultiOrganizationEnterpriseProviderModel
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Create Multi-organization Enterprise Provider";
|
||||
}
|
||||
|
||||
<h1>Create Multi-organization Enterprise Provider</h1>
|
||||
<div>
|
||||
<form class="form-group" method="post" asp-action="CreateMultiOrganizationEnterprise">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="OwnerEmail"></label>
|
||||
<input type="text" class="form-control" asp-for="OwnerEmail">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
@{
|
||||
var multiOrgPlans = new List<PlanType>
|
||||
{
|
||||
PlanType.EnterpriseAnnually,
|
||||
PlanType.EnterpriseMonthly
|
||||
};
|
||||
}
|
||||
<label asp-for="Plan"></label>
|
||||
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label asp-for="EnterpriseSeatMinimum"></label>
|
||||
<input type="number" class="form-control" asp-for="EnterpriseSeatMinimum">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
||||
</form>
|
||||
</div>
|
25
src/Admin/AdminConsole/Views/Providers/CreateReseller.cshtml
Normal file
25
src/Admin/AdminConsole/Views/Providers/CreateReseller.cshtml
Normal file
@ -0,0 +1,25 @@
|
||||
@model CreateResellerProviderModel
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Create Reseller Provider";
|
||||
}
|
||||
|
||||
<h1>Create Reseller Provider</h1>
|
||||
<div>
|
||||
<form class="form-group" method="post" asp-action="CreateReseller">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Name"></label>
|
||||
<input type="text" class="form-control" asp-for="Name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="BusinessName"></label>
|
||||
<input type="text" class="form-control" asp-for="BusinessName">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="BillingEmail"></label>
|
||||
<input type="text" class="form-control" asp-for="BillingEmail">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
||||
</form>
|
||||
</div>
|
19
src/Admin/Enums/HtmlHelperExtensions.cs
Normal file
19
src/Admin/Enums/HtmlHelperExtensions.cs
Normal file
@ -0,0 +1,19 @@
|
||||
|
||||
using Bit.SharedWeb.Utilities;
|
||||
|
||||
// ReSharper disable once CheckNamespace
|
||||
namespace Microsoft.AspNetCore.Mvc.Rendering;
|
||||
|
||||
public static class HtmlHelper
|
||||
{
|
||||
public static IEnumerable<SelectListItem> GetEnumSelectList<T>(this IHtmlHelper htmlHelper, IEnumerable<T> values)
|
||||
where T : Enum
|
||||
{
|
||||
return values.Select(v => new SelectListItem
|
||||
{
|
||||
Text = v.GetDisplayAttribute().Name,
|
||||
Value = v.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@ -4,8 +4,10 @@ namespace Bit.Core.AdminConsole.Enums.Provider;
|
||||
|
||||
public enum ProviderType : byte
|
||||
{
|
||||
[Display(ShortName = "MSP", Name = "Managed Service Provider", Description = "Access to clients organization")]
|
||||
[Display(ShortName = "MSP", Name = "Managed Service Provider", Description = "Access to clients organization", Order = 0)]
|
||||
Msp = 0,
|
||||
[Display(ShortName = "Reseller", Name = "Reseller", Description = "Access to clients billing")]
|
||||
[Display(ShortName = "Reseller", Name = "Reseller", Description = "Access to clients billing", Order = 1000)]
|
||||
Reseller = 1,
|
||||
[Display(ShortName = "MOE", Name = "Multi-organization Enterprise", Description = "Access to multiple organizations", Order = 1)]
|
||||
MultiOrganizationEnterprise = 2,
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Billing.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
|
||||
@ -6,4 +7,5 @@ public interface ICreateProviderCommand
|
||||
{
|
||||
Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats);
|
||||
Task CreateResellerAsync(Provider provider);
|
||||
Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats);
|
||||
}
|
||||
|
@ -6,6 +6,14 @@ using Bit.Core.Utilities;
|
||||
namespace Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
public enum RegisterFinishTokenType : byte
|
||||
{
|
||||
EmailVerification = 1,
|
||||
OrganizationInvite = 2,
|
||||
OrgSponsoredFreeFamilyPlan = 3,
|
||||
EmergencyAccessInvite = 4,
|
||||
ProviderInvite = 5,
|
||||
}
|
||||
|
||||
public class RegisterFinishRequestModel : IValidatableObject
|
||||
{
|
||||
@ -36,6 +44,10 @@ public class RegisterFinishRequestModel : IValidatableObject
|
||||
public string? AcceptEmergencyAccessInviteToken { get; set; }
|
||||
public Guid? AcceptEmergencyAccessId { get; set; }
|
||||
|
||||
public string? ProviderInviteToken { get; set; }
|
||||
|
||||
public Guid? ProviderUserId { get; set; }
|
||||
|
||||
public User ToUser()
|
||||
{
|
||||
var user = new User
|
||||
@ -54,6 +66,32 @@ public class RegisterFinishRequestModel : IValidatableObject
|
||||
return user;
|
||||
}
|
||||
|
||||
public RegisterFinishTokenType GetTokenType()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(EmailVerificationToken))
|
||||
{
|
||||
return RegisterFinishTokenType.EmailVerification;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(OrgInviteToken) && OrganizationUserId.HasValue)
|
||||
{
|
||||
return RegisterFinishTokenType.OrganizationInvite;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(OrgSponsoredFreeFamilyPlanToken))
|
||||
{
|
||||
return RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(AcceptEmergencyAccessInviteToken) && AcceptEmergencyAccessId.HasValue)
|
||||
{
|
||||
return RegisterFinishTokenType.EmergencyAccessInvite;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(ProviderInviteToken) && ProviderUserId.HasValue)
|
||||
{
|
||||
return RegisterFinishTokenType.ProviderInvite;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Invalid token type.");
|
||||
}
|
||||
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
|
@ -61,4 +61,16 @@ public interface IRegisterUserCommand
|
||||
public Task<IdentityResult> RegisterUserViaAcceptEmergencyAccessInviteToken(User user, string masterPasswordHash,
|
||||
string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new user with a given master password hash, sends a welcome email, and raises the signup reference event.
|
||||
/// If a valid token is provided, the user will be created with their email verified.
|
||||
/// If the token is invalid or expired, an error will be thrown.
|
||||
/// </summary>
|
||||
/// <param name="user">The <see cref="User"/> to create</param>
|
||||
/// <param name="masterPasswordHash">The hashed master password the user entered</param>
|
||||
/// <param name="providerInviteToken">The provider invite token sent to the user via email</param>
|
||||
/// <param name="providerUserId">The provider user id which is used to validate the invite token</param>
|
||||
/// <returns><see cref="IdentityResult"/></returns>
|
||||
public Task<IdentityResult> RegisterUserViaProviderInviteToken(User user, string masterPasswordHash, string providerInviteToken, Guid providerUserId);
|
||||
|
||||
}
|
||||
|
@ -32,6 +32,7 @@ public class RegisterUserCommand : IRegisterUserCommand
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;
|
||||
private readonly IDataProtector _organizationServiceDataProtector;
|
||||
private readonly IDataProtector _providerServiceDataProtector;
|
||||
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
@ -75,6 +76,8 @@ public class RegisterUserCommand : IRegisterUserCommand
|
||||
|
||||
_validateRedemptionTokenCommand = validateRedemptionTokenCommand;
|
||||
_emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory;
|
||||
|
||||
_providerServiceDataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
}
|
||||
|
||||
|
||||
@ -303,6 +306,25 @@ public class RegisterUserCommand : IRegisterUserCommand
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IdentityResult> RegisterUserViaProviderInviteToken(User user, string masterPasswordHash,
|
||||
string providerInviteToken, Guid providerUserId)
|
||||
{
|
||||
ValidateOpenRegistrationAllowed();
|
||||
ValidateProviderInviteToken(providerInviteToken, providerUserId, user.Email);
|
||||
|
||||
user.EmailVerified = true;
|
||||
user.ApiKey = CoreHelpers.SecureRandomString(30); // API key can't be null.
|
||||
|
||||
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
|
||||
if (result == IdentityResult.Success)
|
||||
{
|
||||
await _mailService.SendWelcomeEmailAsync(user);
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ValidateOpenRegistrationAllowed()
|
||||
{
|
||||
// We validate open registration on send of initial email and here b/c a user could technically start the
|
||||
@ -333,6 +355,15 @@ public class RegisterUserCommand : IRegisterUserCommand
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateProviderInviteToken(string providerInviteToken, Guid providerUserId, string userEmail)
|
||||
{
|
||||
if (!CoreHelpers.TokenIsValid("ProviderUserInvite", _providerServiceDataProtector, providerInviteToken, userEmail, providerUserId,
|
||||
_globalSettings.OrganizationInviteExpirationHours))
|
||||
{
|
||||
throw new BadRequestException("Invalid provider invite token.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private RegistrationEmailVerificationTokenable ValidateRegistrationEmailVerificationTokenable(string emailVerificationToken, string userEmail)
|
||||
{
|
||||
|
@ -117,7 +117,6 @@ public static class FeatureFlagKeys
|
||||
public const string RestrictProviderAccess = "restrict-provider-access";
|
||||
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
|
||||
public const string VaultBulkManagementAction = "vault-bulk-management-action";
|
||||
public const string BulkDeviceApproval = "bulk-device-approval";
|
||||
public const string MemberAccessReport = "ac-2059-member-access-report";
|
||||
public const string BlockLegacyUsers = "block-legacy-users";
|
||||
public const string InlineMenuFieldQualification = "inline-menu-field-qualification";
|
||||
@ -146,8 +145,10 @@ public static class FeatureFlagKeys
|
||||
public const string RemoveServerVersionHeader = "remove-server-version-header";
|
||||
public const string AccessIntelligence = "pm-13227-access-intelligence";
|
||||
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
|
||||
public const string PM12275_MultiOrganizationEnterprises = "pm-12275-multi-organization-enterprises";
|
||||
public const string Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions";
|
||||
public const string LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split";
|
||||
public const string GeneratorToolsModernization = "generator-tools-modernization";
|
||||
|
||||
public static List<string> GetAllKeys()
|
||||
{
|
||||
@ -163,7 +164,6 @@ public static class FeatureFlagKeys
|
||||
return new Dictionary<string, string>()
|
||||
{
|
||||
{ DuoRedirect, "true" },
|
||||
{ BulkDeviceApproval, "true" },
|
||||
{ CipherKeyEncryption, "true" },
|
||||
};
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core;
|
||||
using System.Diagnostics;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Auth.Models.Api.Response.Accounts;
|
||||
@ -149,40 +150,44 @@ public class AccountsController : Controller
|
||||
IdentityResult identityResult = null;
|
||||
var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays);
|
||||
|
||||
if (!string.IsNullOrEmpty(model.OrgInviteToken) && model.OrganizationUserId.HasValue)
|
||||
switch (model.GetTokenType())
|
||||
{
|
||||
identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash,
|
||||
model.OrgInviteToken, model.OrganizationUserId);
|
||||
case RegisterFinishTokenType.EmailVerification:
|
||||
identityResult =
|
||||
await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash,
|
||||
model.EmailVerificationToken);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
break;
|
||||
case RegisterFinishTokenType.OrganizationInvite:
|
||||
identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash,
|
||||
model.OrgInviteToken, model.OrganizationUserId);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
break;
|
||||
case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan:
|
||||
identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
break;
|
||||
case RegisterFinishTokenType.EmergencyAccessInvite:
|
||||
Debug.Assert(model.AcceptEmergencyAccessId.HasValue);
|
||||
identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash,
|
||||
model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
break;
|
||||
case RegisterFinishTokenType.ProviderInvite:
|
||||
Debug.Assert(model.ProviderUserId.HasValue);
|
||||
identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash,
|
||||
model.ProviderInviteToken, model.ProviderUserId.Value);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new BadRequestException("Invalid registration finish request");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(model.OrgSponsoredFreeFamilyPlanToken))
|
||||
{
|
||||
identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(model.AcceptEmergencyAccessInviteToken) && model.AcceptEmergencyAccessId.HasValue)
|
||||
{
|
||||
identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash,
|
||||
model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(model.EmailVerificationToken))
|
||||
{
|
||||
throw new BadRequestException("Invalid registration finish request");
|
||||
}
|
||||
|
||||
identityResult =
|
||||
await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash,
|
||||
model.EmailVerificationToken);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
|
||||
}
|
||||
|
||||
private async Task<RegisterResponseModel> ProcessRegistrationResult(IdentityResult result, User user, bool delaysEnabled)
|
||||
|
@ -0,0 +1,251 @@
|
||||
using Bit.Admin.AdminConsole.Controllers;
|
||||
using Bit.Admin.AdminConsole.Models;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReceivedExtensions;
|
||||
|
||||
namespace Admin.Test.AdminConsole.Controllers;
|
||||
|
||||
[ControllerCustomize(typeof(ProvidersController))]
|
||||
[SutProviderCustomize]
|
||||
public class ProvidersControllerTests
|
||||
{
|
||||
#region CreateMspAsync
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task CreateMspAsync_WithValidModel_CreatesProvider(
|
||||
CreateMspProviderModel model,
|
||||
SutProvider<ProvidersController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
var actual = await sutProvider.Sut.CreateMsp(model);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(actual);
|
||||
await sutProvider.GetDependency<ICreateProviderCommand>()
|
||||
.Received(Quantity.Exactly(1))
|
||||
.CreateMspAsync(
|
||||
Arg.Is<Provider>(x => x.Type == ProviderType.Msp),
|
||||
model.OwnerEmail,
|
||||
model.TeamsMonthlySeatMinimum,
|
||||
model.EnterpriseMonthlySeatMinimum);
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task CreateMspAsync_RedirectsToExpectedPage_AfterCreatingProvider(
|
||||
CreateMspProviderModel model,
|
||||
Guid expectedProviderId,
|
||||
SutProvider<ProvidersController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<ICreateProviderCommand>()
|
||||
.When(x =>
|
||||
x.CreateMspAsync(
|
||||
Arg.Is<Provider>(y => y.Type == ProviderType.Msp),
|
||||
model.OwnerEmail,
|
||||
model.TeamsMonthlySeatMinimum,
|
||||
model.EnterpriseMonthlySeatMinimum))
|
||||
.Do(callInfo =>
|
||||
{
|
||||
var providerArgument = callInfo.ArgAt<Provider>(0);
|
||||
providerArgument.Id = expectedProviderId;
|
||||
});
|
||||
|
||||
// Act
|
||||
var actual = await sutProvider.Sut.CreateMsp(model);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(actual);
|
||||
Assert.IsType<RedirectToActionResult>(actual);
|
||||
var actualResult = (RedirectToActionResult)actual;
|
||||
Assert.Equal("Edit", actualResult.ActionName);
|
||||
Assert.Null(actualResult.ControllerName);
|
||||
Assert.Equal(expectedProviderId, actualResult.RouteValues["Id"]);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region CreateMultiOrganizationEnterpriseAsync
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync_WithValidModel_CreatesProvider(
|
||||
CreateMultiOrganizationEnterpriseProviderModel model,
|
||||
SutProvider<ProvidersController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(actual);
|
||||
await sutProvider.GetDependency<ICreateProviderCommand>()
|
||||
.Received(Quantity.Exactly(1))
|
||||
.CreateMultiOrganizationEnterpriseAsync(
|
||||
Arg.Is<Provider>(x => x.Type == ProviderType.MultiOrganizationEnterprise),
|
||||
model.OwnerEmail,
|
||||
Arg.Is<PlanType>(y => y == model.Plan),
|
||||
model.EnterpriseSeatMinimum);
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.Received(Quantity.Exactly(1))
|
||||
.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises);
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync_RedirectsToExpectedPage_AfterCreatingProvider(
|
||||
CreateMultiOrganizationEnterpriseProviderModel model,
|
||||
Guid expectedProviderId,
|
||||
SutProvider<ProvidersController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<ICreateProviderCommand>()
|
||||
.When(x =>
|
||||
x.CreateMultiOrganizationEnterpriseAsync(
|
||||
Arg.Is<Provider>(y => y.Type == ProviderType.MultiOrganizationEnterprise),
|
||||
model.OwnerEmail,
|
||||
Arg.Is<PlanType>(y => y == model.Plan),
|
||||
model.EnterpriseSeatMinimum))
|
||||
.Do(callInfo =>
|
||||
{
|
||||
var providerArgument = callInfo.ArgAt<Provider>(0);
|
||||
providerArgument.Id = expectedProviderId;
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(actual);
|
||||
Assert.IsType<RedirectToActionResult>(actual);
|
||||
var actualResult = (RedirectToActionResult)actual;
|
||||
Assert.Equal("Edit", actualResult.ActionName);
|
||||
Assert.Null(actualResult.ControllerName);
|
||||
Assert.Equal(expectedProviderId, actualResult.RouteValues["Id"]);
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync_ChecksFeatureFlag(
|
||||
CreateMultiOrganizationEnterpriseProviderModel model,
|
||||
SutProvider<ProvidersController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.CreateMultiOrganizationEnterprise(model);
|
||||
|
||||
// Assert
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.Received(Quantity.Exactly(1))
|
||||
.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises);
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync_RedirectsToProviderTypeSelectionPage_WhenFeatureFlagIsDisabled(
|
||||
CreateMultiOrganizationEnterpriseProviderModel model,
|
||||
SutProvider<ProvidersController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model);
|
||||
|
||||
// Assert
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.Received(Quantity.Exactly(1))
|
||||
.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises);
|
||||
|
||||
Assert.IsType<RedirectToActionResult>(actual);
|
||||
var actualResult = (RedirectToActionResult)actual;
|
||||
Assert.Equal("Create", actualResult.ActionName);
|
||||
Assert.Null(actualResult.ControllerName);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region CreateResellerAsync
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task CreateResellerAsync_WithValidModel_CreatesProvider(
|
||||
CreateResellerProviderModel model,
|
||||
SutProvider<ProvidersController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var actual = await sutProvider.Sut.CreateReseller(model);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(actual);
|
||||
await sutProvider.GetDependency<ICreateProviderCommand>()
|
||||
.Received(Quantity.Exactly(1))
|
||||
.CreateResellerAsync(
|
||||
Arg.Is<Provider>(x => x.Type == ProviderType.Reseller));
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[SutProviderCustomize]
|
||||
[Theory]
|
||||
public async Task CreateResellerAsync_RedirectsToExpectedPage_AfterCreatingProvider(
|
||||
CreateResellerProviderModel model,
|
||||
Guid expectedProviderId,
|
||||
SutProvider<ProvidersController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<ICreateProviderCommand>()
|
||||
.When(x =>
|
||||
x.CreateResellerAsync(
|
||||
Arg.Is<Provider>(y => y.Type == ProviderType.Reseller)))
|
||||
.Do(callInfo =>
|
||||
{
|
||||
var providerArgument = callInfo.ArgAt<Provider>(0);
|
||||
providerArgument.Id = expectedProviderId;
|
||||
});
|
||||
|
||||
// Act
|
||||
var actual = await sutProvider.Sut.CreateReseller(model);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(actual);
|
||||
Assert.IsType<RedirectToActionResult>(actual);
|
||||
var actualResult = (RedirectToActionResult)actual;
|
||||
Assert.Equal("Edit", actualResult.ActionName);
|
||||
Assert.Null(actualResult.ControllerName);
|
||||
Assert.Equal(expectedProviderId, actualResult.RouteValues["Id"]);
|
||||
}
|
||||
#endregion
|
||||
}
|
@ -0,0 +1,173 @@
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Auth.Models.Api.Request.Accounts;
|
||||
|
||||
public class RegisterFinishRequestModelTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetTokenType_Returns_EmailVerification(string email, string masterPasswordHash,
|
||||
string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations, string emailVerificationToken)
|
||||
{
|
||||
// Arrange
|
||||
var model = new RegisterFinishRequestModel
|
||||
{
|
||||
Email = email,
|
||||
MasterPasswordHash = masterPasswordHash,
|
||||
UserSymmetricKey = userSymmetricKey,
|
||||
UserAsymmetricKeys = userAsymmetricKeys,
|
||||
Kdf = kdf,
|
||||
KdfIterations = kdfIterations,
|
||||
EmailVerificationToken = emailVerificationToken
|
||||
};
|
||||
|
||||
// Act
|
||||
Assert.Equal(RegisterFinishTokenType.EmailVerification, model.GetTokenType());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetTokenType_Returns_OrganizationInvite(string email, string masterPasswordHash,
|
||||
string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations, string orgInviteToken, Guid organizationUserId)
|
||||
{
|
||||
// Arrange
|
||||
var model = new RegisterFinishRequestModel
|
||||
{
|
||||
Email = email,
|
||||
MasterPasswordHash = masterPasswordHash,
|
||||
UserSymmetricKey = userSymmetricKey,
|
||||
UserAsymmetricKeys = userAsymmetricKeys,
|
||||
Kdf = kdf,
|
||||
KdfIterations = kdfIterations,
|
||||
OrgInviteToken = orgInviteToken,
|
||||
OrganizationUserId = organizationUserId
|
||||
};
|
||||
|
||||
// Act
|
||||
Assert.Equal(RegisterFinishTokenType.OrganizationInvite, model.GetTokenType());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetTokenType_Returns_OrgSponsoredFreeFamilyPlan(string email, string masterPasswordHash,
|
||||
string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations, string orgSponsoredFreeFamilyPlanToken)
|
||||
{
|
||||
// Arrange
|
||||
var model = new RegisterFinishRequestModel
|
||||
{
|
||||
Email = email,
|
||||
MasterPasswordHash = masterPasswordHash,
|
||||
UserSymmetricKey = userSymmetricKey,
|
||||
UserAsymmetricKeys = userAsymmetricKeys,
|
||||
Kdf = kdf,
|
||||
KdfIterations = kdfIterations,
|
||||
OrgSponsoredFreeFamilyPlanToken = orgSponsoredFreeFamilyPlanToken
|
||||
};
|
||||
|
||||
// Act
|
||||
Assert.Equal(RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan, model.GetTokenType());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetTokenType_Returns_EmergencyAccessInvite(string email, string masterPasswordHash,
|
||||
string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId)
|
||||
{
|
||||
// Arrange
|
||||
var model = new RegisterFinishRequestModel
|
||||
{
|
||||
Email = email,
|
||||
MasterPasswordHash = masterPasswordHash,
|
||||
UserSymmetricKey = userSymmetricKey,
|
||||
UserAsymmetricKeys = userAsymmetricKeys,
|
||||
Kdf = kdf,
|
||||
KdfIterations = kdfIterations,
|
||||
AcceptEmergencyAccessInviteToken = acceptEmergencyAccessInviteToken,
|
||||
AcceptEmergencyAccessId = acceptEmergencyAccessId
|
||||
};
|
||||
|
||||
// Act
|
||||
Assert.Equal(RegisterFinishTokenType.EmergencyAccessInvite, model.GetTokenType());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetTokenType_Returns_ProviderInvite(string email, string masterPasswordHash,
|
||||
string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations, string providerInviteToken, Guid providerUserId)
|
||||
{
|
||||
// Arrange
|
||||
var model = new RegisterFinishRequestModel
|
||||
{
|
||||
Email = email,
|
||||
MasterPasswordHash = masterPasswordHash,
|
||||
UserSymmetricKey = userSymmetricKey,
|
||||
UserAsymmetricKeys = userAsymmetricKeys,
|
||||
Kdf = kdf,
|
||||
KdfIterations = kdfIterations,
|
||||
ProviderInviteToken = providerInviteToken,
|
||||
ProviderUserId = providerUserId
|
||||
};
|
||||
|
||||
// Act
|
||||
Assert.Equal(RegisterFinishTokenType.ProviderInvite, model.GetTokenType());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetTokenType_Returns_Invalid(string email, string masterPasswordHash,
|
||||
string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations)
|
||||
{
|
||||
// Arrange
|
||||
var model = new RegisterFinishRequestModel
|
||||
{
|
||||
Email = email,
|
||||
MasterPasswordHash = masterPasswordHash,
|
||||
UserSymmetricKey = userSymmetricKey,
|
||||
UserAsymmetricKeys = userAsymmetricKeys,
|
||||
Kdf = kdf,
|
||||
KdfIterations = kdfIterations
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = Assert.Throws<InvalidOperationException>(() => model.GetTokenType());
|
||||
Assert.Equal("Invalid token type.", result.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void ToUser_Returns_User(string email, string masterPasswordHash, string masterPasswordHint,
|
||||
string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations,
|
||||
int? kdfMemory, int? kdfParallelism)
|
||||
{
|
||||
// Arrange
|
||||
var model = new RegisterFinishRequestModel
|
||||
{
|
||||
Email = email,
|
||||
MasterPasswordHash = masterPasswordHash,
|
||||
MasterPasswordHint = masterPasswordHint,
|
||||
UserSymmetricKey = userSymmetricKey,
|
||||
UserAsymmetricKeys = userAsymmetricKeys,
|
||||
Kdf = kdf,
|
||||
KdfIterations = kdfIterations,
|
||||
KdfMemory = kdfMemory,
|
||||
KdfParallelism = kdfParallelism
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.ToUser();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(email, result.Email);
|
||||
Assert.Equal(masterPasswordHint, result.MasterPasswordHint);
|
||||
Assert.Equal(kdf, result.Kdf);
|
||||
Assert.Equal(kdfIterations, result.KdfIterations);
|
||||
Assert.Equal(kdfMemory, result.KdfMemory);
|
||||
Assert.Equal(kdfParallelism, result.KdfParallelism);
|
||||
Assert.Equal(userSymmetricKey, result.Key);
|
||||
Assert.Equal(userAsymmetricKeys.PublicKey, result.PublicKey);
|
||||
Assert.Equal(userAsymmetricKeys.EncryptedPrivateKey, result.PrivateKey);
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using System.Text;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Entities;
|
||||
@ -19,7 +20,9 @@ using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
@ -28,8 +31,10 @@ namespace Bit.Core.Test.Auth.UserFeatures.Registration;
|
||||
[SutProviderCustomize]
|
||||
public class RegisterUserCommandTests
|
||||
{
|
||||
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
// RegisterUser tests
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RegisterUser_Succeeds(SutProvider<RegisterUserCommand> sutProvider, User user)
|
||||
@ -86,7 +91,10 @@ public class RegisterUserCommandTests
|
||||
.RaiseEventAsync(Arg.Any<ReferenceEvent>());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
// RegisterUserWithOrganizationInviteToken tests
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
|
||||
// Simple happy path test
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
@ -312,7 +320,10 @@ public class RegisterUserCommandTests
|
||||
Assert.Equal(expectedErrorMessage, exception.Message);
|
||||
}
|
||||
|
||||
// RegisterUserViaEmailVerificationToken
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
// RegisterUserViaEmailVerificationToken tests
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RegisterUserViaEmailVerificationToken_Succeeds(SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, string emailVerificationToken, bool receiveMarketingMaterials)
|
||||
@ -382,10 +393,9 @@ public class RegisterUserCommandTests
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken
|
||||
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
// RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken tests
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
@ -452,7 +462,9 @@ public class RegisterUserCommandTests
|
||||
Assert.Equal("Open registration has been disabled by the system administrator.", result.Message);
|
||||
}
|
||||
|
||||
// RegisterUserViaAcceptEmergencyAccessInviteToken
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
// RegisterUserViaAcceptEmergencyAccessInviteToken tests
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
@ -495,8 +507,6 @@ public class RegisterUserCommandTests
|
||||
.RaiseEventAsync(Arg.Is<ReferenceEvent>(refEvent => refEvent.Type == ReferenceEventType.Signup));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_InvalidToken_ThrowsBadRequestException(SutProvider<RegisterUserCommand> sutProvider, User user,
|
||||
@ -536,5 +546,140 @@ public class RegisterUserCommandTests
|
||||
Assert.Equal("Open registration has been disabled by the system administrator.", result.Message);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
// RegisterUserViaProviderInviteToken tests
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RegisterUserViaProviderInviteToken_Succeeds(SutProvider<RegisterUserCommand> sutProvider,
|
||||
User user, string masterPasswordHash, Guid providerUserId)
|
||||
{
|
||||
// Arrange
|
||||
// Start with plaintext
|
||||
var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow);
|
||||
var decryptedProviderInviteToken = $"ProviderUserInvite {providerUserId} {user.Email} {nowMillis}";
|
||||
|
||||
// Get the byte array of the plaintext
|
||||
var decryptedProviderInviteTokenByteArray = Encoding.UTF8.GetBytes(decryptedProviderInviteToken);
|
||||
|
||||
// Base64 encode the byte array (this is passed to protector.protect(bytes))
|
||||
var base64EncodedProviderInvToken = WebEncoders.Base64UrlEncode(decryptedProviderInviteTokenByteArray);
|
||||
|
||||
var mockDataProtector = Substitute.For<IDataProtector>();
|
||||
|
||||
// Given any byte array, just return the decryptedProviderInviteTokenByteArray (sidestepping any actual encryption)
|
||||
mockDataProtector.Unprotect(Arg.Any<byte[]>()).Returns(decryptedProviderInviteTokenByteArray);
|
||||
|
||||
sutProvider.GetDependency<IDataProtectionProvider>()
|
||||
.CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(mockDataProtector);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.OrganizationInviteExpirationHours.Returns(120); // 5 days
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CreateUserAsync(user, masterPasswordHash)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
// Using sutProvider in the parameters of the function means that the constructor has already run for the
|
||||
// command so we have to recreate it in order for our mock overrides to be used.
|
||||
sutProvider.Create();
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.RegisterUserViaProviderInviteToken(user, masterPasswordHash, base64EncodedProviderInvToken, providerUserId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Succeeded);
|
||||
|
||||
await sutProvider.GetDependency<IUserService>()
|
||||
.Received(1)
|
||||
.CreateUserAsync(Arg.Is<User>(u => u.Name == user.Name && u.EmailVerified == true && u.ApiKey != null), masterPasswordHash);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendWelcomeEmailAsync(user);
|
||||
|
||||
await sutProvider.GetDependency<IReferenceEventService>()
|
||||
.Received(1)
|
||||
.RaiseEventAsync(Arg.Is<ReferenceEvent>(refEvent => refEvent.Type == ReferenceEventType.Signup));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RegisterUserViaProviderInviteToken_InvalidToken_ThrowsBadRequestException(SutProvider<RegisterUserCommand> sutProvider,
|
||||
User user, string masterPasswordHash, Guid providerUserId)
|
||||
{
|
||||
// Arrange
|
||||
// Start with plaintext
|
||||
var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow);
|
||||
var decryptedProviderInviteToken = $"ProviderUserInvite {providerUserId} {user.Email} {nowMillis}";
|
||||
|
||||
// Get the byte array of the plaintext
|
||||
var decryptedProviderInviteTokenByteArray = Encoding.UTF8.GetBytes(decryptedProviderInviteToken);
|
||||
|
||||
// Base64 encode the byte array (this is passed to protector.protect(bytes))
|
||||
var base64EncodedProviderInvToken = WebEncoders.Base64UrlEncode(decryptedProviderInviteTokenByteArray);
|
||||
|
||||
var mockDataProtector = Substitute.For<IDataProtector>();
|
||||
|
||||
// Given any byte array, just return the decryptedProviderInviteTokenByteArray (sidestepping any actual encryption)
|
||||
mockDataProtector.Unprotect(Arg.Any<byte[]>()).Returns(decryptedProviderInviteTokenByteArray);
|
||||
|
||||
sutProvider.GetDependency<IDataProtectionProvider>()
|
||||
.CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(mockDataProtector);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.OrganizationInviteExpirationHours.Returns(120); // 5 days
|
||||
|
||||
// Using sutProvider in the parameters of the function means that the constructor has already run for the
|
||||
// command so we have to recreate it in order for our mock overrides to be used.
|
||||
sutProvider.Create();
|
||||
|
||||
// Act & Assert
|
||||
var result = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.RegisterUserViaProviderInviteToken(user, masterPasswordHash, base64EncodedProviderInvToken, Guid.NewGuid()));
|
||||
Assert.Equal("Invalid provider invite token.", result.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RegisterUserViaProviderInviteToken_DisabledOpenRegistration_ThrowsBadRequestException(SutProvider<RegisterUserCommand> sutProvider,
|
||||
User user, string masterPasswordHash, Guid providerUserId)
|
||||
{
|
||||
// Arrange
|
||||
// Start with plaintext
|
||||
var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow);
|
||||
var decryptedProviderInviteToken = $"ProviderUserInvite {providerUserId} {user.Email} {nowMillis}";
|
||||
|
||||
// Get the byte array of the plaintext
|
||||
var decryptedProviderInviteTokenByteArray = Encoding.UTF8.GetBytes(decryptedProviderInviteToken);
|
||||
|
||||
// Base64 encode the byte array (this is passed to protector.protect(bytes))
|
||||
var base64EncodedProviderInvToken = WebEncoders.Base64UrlEncode(decryptedProviderInviteTokenByteArray);
|
||||
|
||||
var mockDataProtector = Substitute.For<IDataProtector>();
|
||||
|
||||
// Given any byte array, just return the decryptedProviderInviteTokenByteArray (sidestepping any actual encryption)
|
||||
mockDataProtector.Unprotect(Arg.Any<byte[]>()).Returns(decryptedProviderInviteTokenByteArray);
|
||||
|
||||
sutProvider.GetDependency<IDataProtectionProvider>()
|
||||
.CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(mockDataProtector);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>()
|
||||
.DisableUserRegistration = true;
|
||||
|
||||
// Using sutProvider in the parameters of the function means that the constructor has already run for the
|
||||
// command so we have to recreate it in order for our mock overrides to be used.
|
||||
sutProvider.Create();
|
||||
|
||||
// Act & Assert
|
||||
var result = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.RegisterUserViaProviderInviteToken(user, masterPasswordHash, base64EncodedProviderInvToken, providerUserId));
|
||||
Assert.Equal("Open registration has been disabled by the system administrator.", result.Message);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
@ -9,10 +10,12 @@ using Bit.Core.Models.Business.Tokenables;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Identity.Models.Request.Accounts;
|
||||
using Bit.IntegrationTestCommon.Factories;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
@ -470,6 +473,80 @@ public class AccountsControllerTests : IClassFixture<IdentityApplicationFactory>
|
||||
Assert.Equal(kdfParallelism, user.KdfParallelism);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RegistrationWithEmailVerification_WithProviderInviteToken_Succeeds(
|
||||
[StringLength(1000)] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, string userSymmetricKey,
|
||||
KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism)
|
||||
{
|
||||
|
||||
// Localize factory to just this test.
|
||||
var localFactory = new IdentityApplicationFactory();
|
||||
|
||||
// Hardcoded, valid data
|
||||
var email = "jsnider+local253@bitwarden.com";
|
||||
var providerUserId = new Guid("c6fdba35-2e52-43b4-8fb7-b211011d154a");
|
||||
var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow);
|
||||
var decryptedProviderInviteToken = $"ProviderUserInvite {providerUserId} {email} {nowMillis}";
|
||||
// var providerInviteToken = await GetValidProviderInviteToken(localFactory, email, providerUserId);
|
||||
|
||||
// Get the byte array of the plaintext
|
||||
var decryptedProviderInviteTokenByteArray = Encoding.UTF8.GetBytes(decryptedProviderInviteToken);
|
||||
|
||||
// Base64 encode the byte array (this is passed to protector.protect(bytes))
|
||||
var base64EncodedProviderInvToken = WebEncoders.Base64UrlEncode(decryptedProviderInviteTokenByteArray);
|
||||
|
||||
var mockDataProtector = Substitute.For<IDataProtector>();
|
||||
mockDataProtector.Unprotect(Arg.Any<byte[]>()).Returns(decryptedProviderInviteTokenByteArray);
|
||||
|
||||
localFactory.SubstituteService<IDataProtectionProvider>(dataProtectionProvider =>
|
||||
{
|
||||
dataProtectionProvider.CreateProtector(Arg.Any<string>())
|
||||
.Returns(mockDataProtector);
|
||||
});
|
||||
|
||||
// As token contains now milliseconds for when it was created, create 1k year timespan for expiration
|
||||
// to ensure token is valid for a good long while.
|
||||
localFactory.UpdateConfiguration("globalSettings:OrganizationInviteExpirationHours", "8760000");
|
||||
|
||||
var registerFinishReqModel = new RegisterFinishRequestModel
|
||||
{
|
||||
Email = email,
|
||||
MasterPasswordHash = masterPasswordHash,
|
||||
MasterPasswordHint = masterPasswordHint,
|
||||
ProviderInviteToken = base64EncodedProviderInvToken,
|
||||
ProviderUserId = providerUserId,
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
|
||||
UserSymmetricKey = userSymmetricKey,
|
||||
UserAsymmetricKeys = userAsymmetricKeys,
|
||||
KdfMemory = kdfMemory,
|
||||
KdfParallelism = kdfParallelism
|
||||
};
|
||||
|
||||
var postRegisterFinishHttpContext = await localFactory.PostRegisterFinishAsync(registerFinishReqModel);
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, postRegisterFinishHttpContext.Response.StatusCode);
|
||||
|
||||
var database = localFactory.GetDatabaseContext();
|
||||
var user = await database.Users
|
||||
.SingleAsync(u => u.Email == email);
|
||||
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Assert user properties match the request model
|
||||
Assert.Equal(email, user.Email);
|
||||
Assert.NotEqual(masterPasswordHash, user.MasterPassword); // We execute server side hashing
|
||||
Assert.NotNull(user.MasterPassword);
|
||||
Assert.Equal(masterPasswordHint, user.MasterPasswordHint);
|
||||
Assert.Equal(userSymmetricKey, user.Key);
|
||||
Assert.Equal(userAsymmetricKeys.EncryptedPrivateKey, user.PrivateKey);
|
||||
Assert.Equal(userAsymmetricKeys.PublicKey, user.PublicKey);
|
||||
Assert.Equal(KdfType.PBKDF2_SHA256, user.Kdf);
|
||||
Assert.Equal(AuthConstants.PBKDF2_ITERATIONS.Default, user.KdfIterations);
|
||||
Assert.Equal(kdfMemory, user.KdfMemory);
|
||||
Assert.Equal(kdfParallelism, user.KdfParallelism);
|
||||
}
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PostRegisterVerificationEmailClicked_Success(
|
||||
@ -527,4 +604,5 @@ public class AccountsControllerTests : IClassFixture<IdentityApplicationFactory>
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -57,6 +57,16 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allows you to add your own services to the application as required.
|
||||
/// </summary>
|
||||
/// <param name="configure">The service collection you want added to the test service collection.</param>
|
||||
/// <remarks>This needs to be ran BEFORE making any calls through the factory to take effect.</remarks>
|
||||
public void ConfigureServices(Action<IServiceCollection> configure)
|
||||
{
|
||||
_configureTestServices.Add(configure);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add your own configuration provider to the application.
|
||||
/// </summary>
|
||||
|
Reference in New Issue
Block a user