1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

Merge branch 'main' into ac/pm-10338/leave-endpoint-to-log-organizationuser_left

This commit is contained in:
Rui Tomé 2024-11-19 15:30:51 +00:00 committed by GitHub
commit 9fcb06d647
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
143 changed files with 13111 additions and 3426 deletions

View File

@ -28,7 +28,6 @@ jobs:
runs-on: ubuntu-24.04
outputs:
branch: ${{ steps.set-branch.outputs.branch }}
token: ${{ steps.app-token.outputs.token }}
steps:
- name: Set branch
id: set-branch
@ -45,13 +44,6 @@ jobs:
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
- name: Generate GH App token
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
cut_branch:
name: Cut branch
@ -59,11 +51,18 @@ jobs:
needs: setup
runs-on: ubuntu-24.04
steps:
- name: Generate GH App token
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.target_ref }}
token: ${{ needs.setup.outputs.token }}
token: ${{ steps.app-token.outputs.token }}
- name: Check if ${{ needs.setup.outputs.branch }} branch exists
env:
@ -98,11 +97,18 @@ jobs:
with:
version: ${{ inputs.version_number_override }}
- name: Generate GH App token
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
token: ${{ needs.setup.outputs.token }}
token: ${{ steps.app-token.outputs.token }}
- name: Configure Git
run: |
@ -190,11 +196,18 @@ jobs:
- bump_version
- setup
steps:
- name: Generate GH App token
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out main branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
token: ${{ needs.setup.outputs.token }}
token: ${{ steps.app-token.outputs.token }}
- name: Configure Git
run: |

View File

@ -1,5 +1,4 @@
using Bit.Core;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
@ -10,7 +9,6 @@ using Bit.Core.Billing.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -21,35 +19,28 @@ public class CreateProviderCommand : ICreateProviderCommand
private readonly IProviderService _providerService;
private readonly IUserRepository _userRepository;
private readonly IProviderPlanRepository _providerPlanRepository;
private readonly IFeatureService _featureService;
public CreateProviderCommand(
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository,
IProviderService providerService,
IUserRepository userRepository,
IProviderPlanRepository providerPlanRepository,
IFeatureService featureService)
IProviderPlanRepository providerPlanRepository)
{
_providerRepository = providerRepository;
_providerUserRepository = providerUserRepository;
_providerService = providerService;
_userRepository = userRepository;
_providerPlanRepository = providerPlanRepository;
_featureService = featureService;
}
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);
}
await Task.WhenAll(
CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats),
CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats));
}
public async Task CreateResellerAsync(Provider provider)
@ -61,13 +52,8 @@ public class CreateProviderCommand : ICreateProviderCommand
{
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)
{
@ -77,12 +63,7 @@ public class CreateProviderCommand : ICreateProviderCommand
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
}
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (isConsolidatedBillingEnabled)
{
provider.Gateway = GatewayType.Stripe;
}
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending);

View File

@ -1,7 +1,5 @@
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
@ -102,11 +100,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Provider provider,
IEnumerable<string> organizationOwnerEmails)
{
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (isConsolidatedBillingEnabled &&
provider.Status == ProviderStatusType.Billable &&
organization.Status == OrganizationStatusType.Managed &&
if (provider.IsBillable() &&
organization.IsValidClient() &&
!string.IsNullOrEmpty(organization.GatewayCustomerId))
{
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions

View File

@ -8,7 +8,6 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
@ -101,13 +100,6 @@ public class ProviderService : IProviderService
throw new BadRequestException("Invalid owner.");
}
if (!_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
provider.Status = ProviderStatusType.Created;
await _providerRepository.UpsertAsync(provider);
}
else
{
if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
{
throw new BadRequestException("Both address and postal code are required to set up your provider.");
@ -118,7 +110,6 @@ public class ProviderService : IProviderService
provider.GatewaySubscriptionId = subscription.Id;
provider.Status = ProviderStatusType.Billable;
await _providerRepository.UpsertAsync(provider);
}
providerUser.Key = key;
await _providerUserRepository.ReplaceAsync(providerUser);
@ -545,13 +536,9 @@ public class ProviderService : IProviderService
{
var provider = await _providerRepository.GetByIdAsync(providerId);
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && provider.IsBillable();
ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan);
ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan, consolidatedBillingEnabled);
var (organization, _, defaultCollection) = consolidatedBillingEnabled
? await _organizationService.SignupClientAsync(organizationSignup)
: await _organizationService.SignUpAsync(organizationSignup);
var (organization, _, defaultCollection) = await _organizationService.SignupClientAsync(organizationSignup);
var providerOrganization = new ProviderOrganization
{
@ -687,9 +674,7 @@ public class ProviderService : IProviderService
return confirmedOwnersIds.Except(providerUserIds).Any();
}
private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType, bool consolidatedBillingEnabled = false)
{
if (consolidatedBillingEnabled)
private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType)
{
switch (providerType)
{
@ -708,7 +693,6 @@ public class ProviderService : IProviderService
default:
throw new BadRequestException($"Unsupported provider type {providerType}.");
}
}
if (ProviderDisallowedOrganizationTypes.Contains(requestedType))
{

View File

@ -2,17 +2,14 @@
using Bit.Commercial.Core.Billing.Models;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
@ -27,7 +24,6 @@ using Stripe;
namespace Bit.Commercial.Core.Billing;
public class ProviderBillingService(
ICurrentContext currentContext,
IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository,
@ -35,39 +31,77 @@ public class ProviderBillingService(
IProviderInvoiceItemRepository providerInvoiceItemRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository,
IProviderRepository providerRepository,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IProviderBillingService
{
public async Task AssignSeatsToClientOrganization(
Provider provider,
Organization organization,
int seats)
public async Task ChangePlan(ChangeProviderPlanCommand command)
{
ArgumentNullException.ThrowIfNull(organization);
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
if (seats < 0)
if (plan == null)
{
throw new BillingException(
"You cannot assign negative seats to a client.",
"MSP cannot assign negative seats to a client organization");
throw new BadRequestException("Provider plan not found.");
}
if (seats == organization.Seats)
if (plan.PlanType == command.NewPlan)
{
logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned to it", organization.Id, organization.Seats);
return;
}
var seatAdjustment = seats - (organization.Seats ?? 0);
var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
await ScaleSeats(provider, organization.PlanType, seatAdjustment);
plan.PlanType = command.NewPlan;
await providerPlanRepository.ReplaceAsync(plan);
organization.Seats = seats;
Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
var updateOptions = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = oldSubscriptionItem!.Quantity
},
new SubscriptionItemOptions
{
Id = oldSubscriptionItem.Id,
Deleted = true
}
]
};
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
// 1. Retrieve PlanType and PlanName for ProviderPlan
// 2. Assign PlanType & PlanName to Organization
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
foreach (var providerOrganization in providerOrganizations)
{
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (organization == null)
{
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
}
organization.PlanType = command.NewPlan;
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
await organizationRepository.ReplaceAsync(organization);
}
}
public async Task CreateCustomerForClientOrganization(
Provider provider,
@ -171,65 +205,16 @@ public class ProviderBillingService(
return memoryStream.ToArray();
}
public async Task<int> GetAssignedSeatTotalForPlanOrThrow(
Guid providerId,
PlanType planType)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogError(
"Could not find provider ({ID}) when retrieving assigned seat total",
providerId);
throw new BillingException();
}
if (provider.Type == ProviderType.Reseller)
{
logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId);
throw new BillingException();
}
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
var plan = StaticStore.GetPlan(planType);
return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
}
public async Task ScaleSeats(
Provider provider,
PlanType planType,
int seatAdjustment)
{
ArgumentNullException.ThrowIfNull(provider);
var providerPlan = await GetProviderPlanAsync(provider, planType);
if (!provider.SupportsConsolidatedBilling())
{
logger.LogError("Provider ({ProviderID}) cannot scale their seats", provider.Id);
var seatMinimum = providerPlan.SeatMinimum ?? 0;
throw new BillingException();
}
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType);
if (providerPlan == null || !providerPlan.IsConfigured())
{
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} when their matching provider plan is not configured", provider.Id, planType);
throw new BillingException();
}
var seatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0);
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalForPlanOrThrow(provider.Id, planType);
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
@ -256,13 +241,6 @@ public class ProviderBillingService(
else if (currentlyAssignedSeatTotal <= seatMinimum &&
newlyAssignedSeatTotal > seatMinimum)
{
if (!currentContext.ProviderProviderAdmin(provider.Id))
{
logger.LogError("Service user for provider ({ProviderID}) cannot scale a provider's seat count over the seat minimum", provider.Id);
throw new BillingException();
}
await update(
seatMinimum,
newlyAssignedSeatTotal);
@ -291,6 +269,26 @@ public class ProviderBillingService(
}
}
public async Task<bool> SeatAdjustmentResultsInPurchase(
Provider provider,
PlanType planType,
int seatAdjustment)
{
var providerPlan = await GetProviderPlanAsync(provider, planType);
var seatMinimum = providerPlan.SeatMinimum;
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
return
// Below the limit to above the limit
(currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) ||
// Above the limit to further above the limit
(currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal);
}
public async Task<Customer> SetupCustomer(
Provider provider,
TaxInfo taxInfo)
@ -431,75 +429,6 @@ public class ProviderBillingService(
}
}
public async Task ChangePlan(ChangeProviderPlanCommand command)
{
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
if (plan == null)
{
throw new BadRequestException("Provider plan not found.");
}
if (plan.PlanType == command.NewPlan)
{
return;
}
var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
plan.PlanType = command.NewPlan;
await providerPlanRepository.ReplaceAsync(plan);
Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
var updateOptions = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = oldSubscriptionItem!.Quantity
},
new SubscriptionItemOptions
{
Id = oldSubscriptionItem.Id,
Deleted = true
}
]
};
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
// 1. Retrieve PlanType and PlanName for ProviderPlan
// 2. Assign PlanType & PlanName to Organization
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
foreach (var providerOrganization in providerOrganizations)
{
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (organization == null)
{
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
}
organization.PlanType = command.NewPlan;
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
await organizationRepository.ReplaceAsync(organization);
}
}
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
{
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
@ -610,4 +539,32 @@ public class ProviderBillingService(
await providerPlanRepository.ReplaceAsync(providerPlan);
};
// TODO: Replace with SPROC
private async Task<int> GetAssignedSeatTotalAsync(Provider provider, PlanType planType)
{
var providerOrganizations =
await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id);
var plan = StaticStore.GetPlan(planType);
return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
}
// TODO: Replace with SPROC
private async Task<ProviderPlan> GetProviderPlanAsync(Provider provider, PlanType planType)
{
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var providerPlan = providerPlans.FirstOrDefault(x => x.PlanType == planType);
if (providerPlan == null || !providerPlan.IsConfigured())
{
throw new BillingException(message: "Provider plan is missing or misconfigured");
}
return providerPlan;
}
}

View File

@ -23,7 +23,7 @@
@RenderBody()
</div>
<div class="container footer text-muted">
<div class="container footer text-body-secondary">
<div class="row">
<div class="col">
&copy; @DateTime.Now.Year, Bitwarden Inc.

View File

@ -9,10 +9,9 @@
"version": "0.0.0",
"license": "-",
"dependencies": {
"bootstrap": "4.6.2",
"bootstrap": "5.3.3",
"font-awesome": "4.7.0",
"jquery": "3.7.1",
"popper.js": "1.16.1"
"jquery": "3.7.1"
},
"devDependencies": {
"css-loader": "7.1.2",
@ -384,6 +383,17 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@ -702,9 +712,9 @@
}
},
"node_modules/bootstrap": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz",
"integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==",
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
"integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
"funding": [
{
"type": "github",
@ -715,10 +725,8 @@
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"jquery": "1.9.1 - 3",
"popper.js": "^1.16.1"
"@popperjs/core": "^2.11.8"
}
},
"node_modules/braces": {
@ -1577,17 +1585,6 @@
"node": ">=8"
}
},
"node_modules/popper.js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/postcss": {
"version": "8.4.47",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",

View File

@ -8,10 +8,9 @@
"build": "webpack"
},
"dependencies": {
"bootstrap": "4.6.2",
"bootstrap": "5.3.3",
"font-awesome": "4.7.0",
"jquery": "3.7.1",
"popper.js": "1.16.1"
"jquery": "3.7.1"
},
"devDependencies": {
"css-loader": "7.1.2",

View File

@ -13,8 +13,6 @@ module.exports = {
entry: {
site: [
path.resolve(__dirname, paths.sassDir, "site.scss"),
"popper.js",
"bootstrap",
"jquery",
"font-awesome/css/font-awesome.css",

View File

@ -1,5 +1,4 @@
using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
@ -155,9 +154,6 @@ public class RemoveOrganizationFromProviderCommandTests
"b@example.com"
]);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(false);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
.Returns(GetSubscription(organization.GatewaySubscriptionId));
@ -222,9 +218,6 @@ public class RemoveOrganizationFromProviderCommandTests
"b@example.com"
]);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription

View File

@ -1,6 +1,5 @@
using Bit.Commercial.Core.AdminConsole.Services;
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
@ -55,8 +54,8 @@ public class ProviderServiceTests
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key,
[ProviderUser(ProviderUserStatusType.Confirmed, ProviderUserType.ProviderAdmin)] ProviderUser providerUser,
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo,
[ProviderUser] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
{
providerUser.ProviderId = provider.Id;
@ -71,37 +70,6 @@ public class ProviderServiceTests
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
sutProvider.Create();
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key);
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(provider);
await sutProvider.GetDependency<IProviderUserRepository>().Received()
.ReplaceAsync(Arg.Is<ProviderUser>(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key));
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_ConsolidatedBilling_Success(User user, Provider provider, string key, TaxInfo taxInfo,
[ProviderUser(ProviderUserStatusType.Confirmed, ProviderUserType.ProviderAdmin)] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
{
providerUser.ProviderId = provider.Id;
providerUser.UserId = user.Id;
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByIdAsync(user.Id).Returns(user);
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
@ -489,7 +457,7 @@ public class ProviderServiceTests
public async Task AddOrganization_OrganizationHasSecretsManager_Throws(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider)
{
organization.PlanType = PlanType.EnterpriseAnnually;
organization.PlanType = PlanType.EnterpriseMonthly;
organization.UseSecretsManager = true;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
@ -506,7 +474,7 @@ public class ProviderServiceTests
public async Task AddOrganization_Success(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider)
{
organization.PlanType = PlanType.EnterpriseAnnually;
organization.PlanType = PlanType.EnterpriseMonthly;
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
@ -549,8 +517,8 @@ public class ProviderServiceTests
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
var expectedPlanType = PlanType.EnterpriseAnnually;
organization.PlanType = PlanType.EnterpriseAnnually;
var expectedPlanType = PlanType.EnterpriseMonthly;
organization.PlanType = PlanType.EnterpriseMonthly;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
@ -579,12 +547,12 @@ public class ProviderServiceTests
BackdateProviderCreationDate(provider, newCreationDate);
provider.Type = ProviderType.Msp;
organization.PlanType = PlanType.EnterpriseAnnually;
organization.Plan = "Enterprise (Annually)";
organization.PlanType = PlanType.EnterpriseMonthly;
organization.Plan = "Enterprise (Monthly)";
var expectedPlanType = PlanType.EnterpriseAnnually2020;
var expectedPlanType = PlanType.EnterpriseMonthly2020;
var expectedPlanId = "2020-enterprise-org-seat-annually";
var expectedPlanId = "2020-enterprise-org-seat-monthly";
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
@ -663,11 +631,11 @@ public class ProviderServiceTests
public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup,
Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider)
{
organizationSignup.Plan = PlanType.EnterpriseAnnually;
organizationSignup.Plan = PlanType.EnterpriseMonthly;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup)
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
.Returns((organization, null as OrganizationUser, new Collection()));
var providerOrganization =
@ -688,7 +656,7 @@ public class ProviderServiceTests
}
[Theory, OrganizationCustomize, BitAutoData]
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvalidPlanType_ThrowsBadRequestException(
public async Task CreateOrganizationAsync_InvalidPlanType_ThrowsBadRequestException(
Provider provider,
OrganizationSignup organizationSignup,
Organization organization,
@ -696,8 +664,6 @@ public class ProviderServiceTests
User user,
SutProvider<ProviderService> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
provider.Type = ProviderType.Msp;
provider.Status = ProviderStatusType.Billable;
@ -717,7 +683,7 @@ public class ProviderServiceTests
}
[Theory, OrganizationCustomize, BitAutoData]
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvokeSignupClientAsync(
public async Task CreateOrganizationAsync_InvokeSignupClientAsync(
Provider provider,
OrganizationSignup organizationSignup,
Organization organization,
@ -725,8 +691,6 @@ public class ProviderServiceTests
User user,
SutProvider<ProviderService> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
provider.Type = ProviderType.Msp;
provider.Status = ProviderStatusType.Billable;
@ -771,11 +735,11 @@ public class ProviderServiceTests
(Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail,
User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection)
{
organizationSignup.Plan = PlanType.EnterpriseAnnually;
organizationSignup.Plan = PlanType.EnterpriseMonthly;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup)
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
.Returns((organization, null as OrganizationUser, defaultCollection));
var providerOrganization =

View File

@ -5,8 +5,10 @@ using Bit.Admin.Services;
using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
@ -53,8 +55,8 @@ public class OrganizationsController : Controller
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IFeatureService _featureService;
private readonly IProviderBillingService _providerBillingService;
private readonly IFeatureService _featureService;
public OrganizationsController(
IOrganizationService organizationService,
@ -80,8 +82,8 @@ public class OrganizationsController : Controller
IServiceAccountRepository serviceAccountRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IFeatureService featureService,
IProviderBillingService providerBillingService)
IProviderBillingService providerBillingService,
IFeatureService featureService)
{
_organizationService = organizationService;
_organizationRepository = organizationRepository;
@ -106,8 +108,8 @@ public class OrganizationsController : Controller
_serviceAccountRepository = serviceAccountRepository;
_providerOrganizationRepository = providerOrganizationRepository;
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_featureService = featureService;
_providerBillingService = providerBillingService;
_featureService = featureService;
}
[RequirePermission(Permission.Org_List_View)]
@ -230,7 +232,23 @@ public class OrganizationsController : Controller
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model)
{
var organization = await GetOrganization(id, model);
var organization = await _organizationRepository.GetByIdAsync(id);
if (organization == null)
{
TempData["Error"] = "Could not find organization to update.";
return RedirectToAction("Index");
}
var existingOrganizationData = new Organization
{
Id = organization.Id,
Status = organization.Status,
PlanType = organization.PlanType,
Seats = organization.Seats
};
UpdateOrganization(organization, model);
if (organization.UseSecretsManager &&
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
@ -239,7 +257,12 @@ public class OrganizationsController : Controller
return RedirectToAction("Edit", new { id });
}
await HandlePotentialProviderSeatScalingAsync(
existingOrganizationData,
model);
await _organizationRepository.ReplaceAsync(organization);
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
{
@ -262,9 +285,7 @@ public class OrganizationsController : Controller
return RedirectToAction("Index");
}
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (consolidatedBillingEnabled && organization.IsValidClient())
if (organization.IsValidClient())
{
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
@ -394,10 +415,9 @@ public class OrganizationsController : Controller
return Json(null);
}
private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model)
{
var organization = await _organizationRepository.GetByIdAsync(id);
private void UpdateOrganization(Organization organization, OrganizationEditModel model)
{
if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox))
{
organization.Enabled = model.Enabled;
@ -449,7 +469,62 @@ public class OrganizationsController : Controller
organization.GatewayCustomerId = model.GatewayCustomerId;
organization.GatewaySubscriptionId = model.GatewaySubscriptionId;
}
}
return organization;
private async Task HandlePotentialProviderSeatScalingAsync(
Organization organization,
OrganizationEditModel update)
{
var scaleMSPOnClientOrganizationUpdate =
_featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate);
if (!scaleMSPOnClientOrganizationUpdate)
{
return;
}
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
// No scaling required
if (provider is not { Type: ProviderType.Msp, Status: ProviderStatusType.Billable } ||
organization is not { Status: OrganizationStatusType.Managed } ||
!organization.Seats.HasValue ||
update is { Seats: null, PlanType: null } ||
update is { PlanType: not PlanType.TeamsMonthly and not PlanType.EnterpriseMonthly } ||
(PlanTypesMatch() && SeatsMatch()))
{
return;
}
// Only scale the plan
if (!PlanTypesMatch() && SeatsMatch())
{
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value);
}
// Only scale the seats
else if (PlanTypesMatch() && !SeatsMatch())
{
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
await _providerBillingService.ScaleSeats(provider, organization.PlanType, seatAdjustment);
}
// Scale both
else if (!PlanTypesMatch() && !SeatsMatch())
{
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
var planTypeAdjustment = organization.Seats.Value;
var totalAdjustment = seatAdjustment + planTypeAdjustment;
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, totalAdjustment);
}
return;
bool PlanTypesMatch()
=> update.PlanType.HasValue && update.PlanType.Value == organization.PlanType;
bool SeatsMatch()
=> update.Seats.HasValue && update.Seats.Value == organization.Seats;
}
}

View File

@ -282,9 +282,7 @@ public class ProvidersController : Controller
await _providerRepository.ReplaceAsync(provider);
await _applicationCacheService.UpsertProviderAbilityAsync(provider);
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (!isConsolidatedBillingEnabled || !provider.IsBillable())
if (!provider.IsBillable())
{
return RedirectToAction("Edit", new { id });
}
@ -340,10 +338,7 @@ public class ProvidersController : Controller
var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id);
var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id);
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (!isConsolidatedBillingEnabled || !provider.IsBillable())
if (!provider.IsBillable())
{
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>());
}

View File

@ -103,19 +103,19 @@
<div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
<div class="ml-auto d-flex">
<div class="ms-auto d-flex">
@if (canInitiateTrial && Model.Provider is null)
{
<button class="btn btn-secondary mr-2" type="button" id="teams-trial">
<button class="btn btn-secondary me-2" type="button" id="teams-trial">
Teams Trial
</button>
<button class="btn btn-secondary mr-2" type="button" id="enterprise-trial">
<button class="btn btn-secondary me-2" type="button" id="enterprise-trial">
Enterprise Trial
</button>
}
@if (canUnlinkFromProvider && Model.Provider is not null)
{
<button class="btn btn-outline-danger mr-2"
<button class="btn btn-outline-danger me-2"
onclick="return unlinkProvider('@Model.Organization.Id');">
Unlink provider
</button>
@ -124,7 +124,7 @@
{
<form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form">
<input type="hidden" name="AdminEmail" id="AdminEmail" />
<button class="btn btn-danger mr-2" type="submit">Request Delete</button>
<button class="btn btn-danger me-2" type="submit">Request Delete</button>
</form>
<form asp-action="Delete" asp-route-id="@Model.Organization.Id"
onsubmit="return confirm('Are you sure you want to hard delete this organization?')">

View File

@ -5,21 +5,31 @@
<h1>Organizations</h1>
<form class="form-inline mb-2" method="get">
<label class="sr-only" asp-for="Name">Name</label>
<input type="text" class="form-control mb-2 mr-2" placeholder="Name" asp-for="Name" name="name">
<label class="sr-only" asp-for="UserEmail">User email</label>
<input type="text" class="form-control mb-2 mr-2" placeholder="User email" asp-for="UserEmail" name="userEmail">
<form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
<div class="col-12">
<label class="visually-hidden" asp-for="Name">Name</label>
<input type="text" class="form-control" placeholder="Name" asp-for="Name" name="name">
</div>
<div class="col-12">
<label class="visually-hidden" asp-for="UserEmail">User email</label>
<input type="text" class="form-control" placeholder="User email" asp-for="UserEmail" name="userEmail">
</div>
@if(!Model.SelfHosted)
{
<label class="sr-only" asp-for="Paid">Customer</label>
<select class="form-control mb-2 mr-2" asp-for="Paid" name="paid">
<div class="col-12">
<label class="visually-hidden" asp-for="Paid">Customer</label>
<select class="form-select" asp-for="Paid" name="paid">
<option asp-selected="!Model.Paid.HasValue" value="">-- Customer --</option>
<option asp-selected="Model.Paid.GetValueOrDefault(false)" value="true">Paid</option>
<option asp-selected="!Model.Paid.GetValueOrDefault(true)" value="false">Freeloader</option>
</select>
</div>
}
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button>
<div class="col-12">
<button type="submit" class="btn btn-primary" title="Search">
<i class="fa fa-search"></i> Search
</button>
</div>
</form>
<div class="table-responsive">
@ -68,7 +78,7 @@
}
else
{
<i class="fa fa-smile-o fa-lg fa-fw text-muted" title="Freeloader"></i>
<i class="fa fa-smile-o fa-lg fa-fw text-body-secondary" title="Freeloader"></i>
}
}
@if(org.MaxStorageGb.HasValue && org.MaxStorageGb > 1)
@ -78,7 +88,7 @@
}
else
{
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted"
<i class="fa fa-plus-square-o fa-lg fa-fw text-body-secondary"
title="No Additional Storage"></i>
}
@if(org.Enabled)
@ -88,7 +98,7 @@
}
else
{
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Disabled"></i>
<i class="fa fa-times-circle-o fa-lg fa-fw text-body-secondary" title="Disabled"></i>
}
@if(org.TwoFactorIsEnabled())
{
@ -96,7 +106,7 @@
}
else
{
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
<i class="fa fa-unlock fa-lg fa-fw text-body-secondary" title="2FA Not Enabled"></i>
}
</td>
</tr>

View File

@ -9,12 +9,18 @@
<h1>Add Existing Organization</h1>
<div class="row mb-2">
<div class="col">
<form class="form-inline mb-2" method="get" asp-route-id="@providerId">
<label class="sr-only" asp-for="OrganizationName"></label>
<input type="text" class="form-control mb-2 mr-2 flex-fill" placeholder="@Html.DisplayNameFor(m => m.OrganizationName)" asp-for="OrganizationName" name="name">
<label class="sr-only" asp-for="OrganizationOwnerEmail"></label>
<input type="email" class="form-control mb-2 mr-2 flex-fill" placeholder="@Html.DisplayNameFor(m => m.OrganizationOwnerEmail)" asp-for="OrganizationOwnerEmail" name="ownerEmail">
<button type="submit" class="btn btn-primary mb-2" title="Search" formmethod="get"><i class="fa fa-search"></i> Search</button>
<form class="row g-3 align-items-center mb-2" method="get" asp-route-id="@providerId">
<div class="col">
<label class="visually-hidden" asp-for="OrganizationName"></label>
<input type="text" class="form-control" placeholder="@Html.DisplayNameFor(m => m.OrganizationName)" asp-for="OrganizationName" name="name">
</div>
<div class="col">
<label class="visually-hidden" asp-for="OrganizationOwnerEmail"></label>
<input type="email" class="form-control" placeholder="@Html.DisplayNameFor(m => m.OrganizationOwnerEmail)" asp-for="OrganizationOwnerEmail" name="ownerEmail">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary" title="Search" formmethod="get"><i class="fa fa-search"></i> Search</button>
</div>
</form>
</div>
</div>

View File

@ -22,23 +22,23 @@
<h1>Create Provider</h1>
<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>
<div class="mb-3">
<label asp-for="Type" class="form-label h2"></label>
@foreach (var providerType in providerTypes)
{
var providerTypeValue = (int)providerType;
<div class="form-group">
<div class="mb-3">
<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}" })
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label", @for = $"providerType-{providerTypeValue}" })
</div>
</div>
</div>
<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}" })
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-body-secondary ps-4", @for = $"providerType-{providerTypeValue}" })
</div>
</div>
</div>

View File

@ -1,39 +1,31 @@
@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">
<form method="post" asp-action="CreateMsp">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="form-group">
<label asp-for="OwnerEmail"></label>
<div class="mb-3">
<label asp-for="OwnerEmail" class="form-label"></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>
<div class="mb-3">
<label asp-for="TeamsMonthlySeatMinimum" class="form-label"></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>
<div class="mb-3">
<label asp-for="EnterpriseMonthlySeatMinimum" class="form-label"></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>

View File

@ -7,17 +7,17 @@
ViewData["Title"] = "Create Multi-organization Enterprise Provider";
}
<h1>Create Multi-organization Enterprise Provider</h1>
<h1 class="mb-4">Create Multi-organization Enterprise Provider</h1>
<div>
<form class="form-group" method="post" asp-action="CreateMultiOrganizationEnterprise">
<form method="post" asp-action="CreateMultiOrganizationEnterprise">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="form-group">
<label asp-for="OwnerEmail"></label>
<div class="mb-3">
<label asp-for="OwnerEmail" class="form-label"></label>
<input type="text" class="form-control" asp-for="OwnerEmail">
</div>
<div class="row">
<div class="col-sm">
<div class="form-group">
<div class="mb-3">
@{
var multiOrgPlans = new List<PlanType>
{
@ -25,19 +25,19 @@
PlanType.EnterpriseMonthly
};
}
<label asp-for="Plan"></label>
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
<label asp-for="Plan" class="form-label"></label>
<select class="form-select" 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>
<div class="mb-3">
<label asp-for="EnterpriseSeatMinimum" class="form-label"></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>
<button type="submit" class="btn btn-primary">Create Provider</button>
</form>
</div>

View File

@ -18,7 +18,7 @@
@await Html.PartialAsync("~/AdminConsole/Views/Shared/_OrganizationForm.cshtml", Model)
<div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
<div class="ml-auto d-flex">
<div class="ms-auto d-flex">
<form asp-controller="Providers" asp-action="Edit" asp-route-id="@Model.Provider.Id"
onsubmit="return confirm('Are you sure you want to cancel?')">
<button class="btn btn-outline-secondary" type="submit">Cancel</button>

View File

@ -6,18 +6,18 @@
<h1>Create Reseller Provider</h1>
<div>
<form class="form-group" method="post" asp-action="CreateReseller">
<form class="mb-3" method="post" asp-action="CreateReseller">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="form-group">
<label asp-for="Name"></label>
<div class="mb-3">
<label asp-for="Name" class="form-label"></label>
<input type="text" class="form-control" asp-for="Name">
</div>
<div class="form-group">
<label asp-for="BusinessName"></label>
<div class="mb-3">
<label asp-for="BusinessName" class="form-label"></label>
<input type="text" class="form-control" asp-for="BusinessName">
</div>
<div class="form-group">
<label asp-for="BillingEmail"></label>
<div class="mb-3">
<label asp-for="BillingEmail" class="form-label"></label>
<input type="text" class="form-control" asp-for="BillingEmail">
</div>
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>

View File

@ -34,21 +34,21 @@
<h2>Billing</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="BillingEmail"></label>
<div class="mb-3">
<label asp-for="BillingEmail" class="form-label"></label>
<input type="email" class="form-control" asp-for="BillingEmail" readonly='@(!canEdit)'>
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="BillingPhone"></label>
<div class="mb-3">
<label asp-for="BillingPhone" class="form-label"></label>
<input type="tel" class="form-control" asp-for="BillingPhone">
</div>
</div>
</div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && Model.Provider.IsBillable())
@if (Model.Provider.IsBillable())
{
switch (Model.Provider.Type)
{
@ -56,58 +56,18 @@
{
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="TeamsMonthlySeatMinimum"></label>
<div class="mb-3">
<label asp-for="TeamsMonthlySeatMinimum" class="form-label"></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>
<div class="mb-3">
<label asp-for="EnterpriseMonthlySeatMinimum" class="form-label"></label>
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="form-group">
<div class="form-group">
<label asp-for="Gateway"></label>
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option>
</select>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="GatewayCustomerId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId">
<div class="input-group-append">
<a href="@Model.GatewayCustomerUrl" class="btn btn-secondary" target="_blank">
<i class="fa fa-external-link"></i>
</a>
</div>
</div>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="GatewaySubscriptionId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
<div class="input-group-append">
<a href="@Model.GatewaySubscriptionUrl" class="btn btn-secondary" target="_blank">
<i class="fa fa-external-link"></i>
</a>
</div>
</div>
</div>
</div>
</div>
break;
}
case ProviderType.MultiOrganizationEnterprise:
@ -116,7 +76,7 @@
{
<div class="row">
<div class="col-sm">
<div class="form-group">
<div class="mb-3">
@{
var multiOrgPlans = new List<PlanType>
{
@ -124,15 +84,15 @@
PlanType.EnterpriseMonthly
};
}
<label asp-for="Plan"></label>
<label asp-for="Plan" class="form-label"></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="EnterpriseMinimumSeats"></label>
<div class="mb-3">
<label asp-for="EnterpriseMinimumSeats" class="form-label"></label>
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats">
</div>
</div>
@ -141,6 +101,40 @@
break;
}
}
<div class="row">
<div class="col-sm">
<div class="mb-3">
<label asp-for="Gateway" class="form-label"></label>
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="mb-3">
<label asp-for="GatewayCustomerId" class="form-label"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId">
<button class="btn btn-secondary" type="button" onclick="window.open('@Model.GatewayCustomerUrl', '_blank')">
<i class="fa fa-external-link"></i>
</button>
</div>
</div>
</div>
<div class="col-sm">
<div class="mb-3">
<label asp-for="GatewaySubscriptionId" class="form-label"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
<button class="btn btn-secondary" type="button" onclick="window.open('@Model.GatewaySubscriptionUrl', '_blank')">
<i class="fa fa-external-link"></i>
</button>
</div>
</div>
</div>
</div>
}
</form>
@await Html.PartialAsync("Organizations", Model)
@ -151,21 +145,21 @@
<div class="modal-dialog">
<div class="modal-content rounded">
<div class="p-3">
<h4 class="font-weight-bolder" id="exampleModalLabel">Request provider deletion</h4>
<h4 class="fw-bolder" id="exampleModalLabel">Request provider deletion</h4>
</div>
<div class="modal-body">
<span class="font-weight-light">
<span class="fw-light">
Enter the email of the provider admin that will receive the request to delete the provider portal.
</span>
<form>
<div class="form-group">
<div class="mb-3">
<label for="provider-email" class="col-form-label">Provider email</label>
<input type="email" class="form-control" id="provider-email">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-pill" onclick="initiateDeleteProvider('@Model.Provider.Id')">Send email request</button>
</div>
</div>
@ -175,21 +169,21 @@
<div class="modal-dialog">
<div class="modal-content rounded">
<div class="p-3">
<h4 class="font-weight-bolder" id="exampleModalLabel">Delete provider</h4>
<h4 class="fw-bolder" id="exampleModalLabel">Delete provider</h4>
</div>
<div class="modal-body">
<span class="font-weight-light">
<span class="fw-light">
This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data.
</span>
<form>
<div class="form-group">
<div class="mb-3">
<label for="provider-name" class="col-form-label">Provider name</label>
<input type="text" class="form-control" id="provider-name">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-pill" onclick="deleteProvider('@Model.Provider.Id');">Delete provider</button>
</div>
</div>
@ -199,12 +193,12 @@
<div class="modal-dialog" role="document">
<div class="modal-content rounded">
<div class="modal-body">
<h4 class="font-weight-bolder">Cannot Delete @Model.Name</h4>
<p class="font-weight-lighter">You must unlink all clients before you can delete @Model.Name.</p>
<h4 class="fw-bolder">Cannot Delete @Model.Name</h4>
<p class="fw-lighter">You must unlink all clients before you can delete @Model.Name.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary btn-pill" data-dismiss="modal">Ok</button>
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary btn-pill" data-bs-dismiss="modal">Ok</button>
</div>
</div>
</div>
@ -214,15 +208,14 @@
<div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
<div class="ml-auto d-flex">
<div class="ms-auto d-flex">
<button class="btn btn-danger" onclick="openRequestDeleteModal(@Model.ProviderOrganizations.Count())">Request Delete</button>
<button id="requestDeletionBtn" hidden="hidden" data-toggle="modal" data-target="#requestDeletionModal"></button>
<button id="requestDeletionBtn" hidden="hidden" data-bs-toggle="modal" data-bs-target="#requestDeletionModal"></button>
<button class="btn btn-outline-danger ml-2" onclick="openDeleteModal(@Model.ProviderOrganizations.Count())">Delete</button>
<button id="deleteBtn" hidden="hidden" data-toggle="modal" data-target="#DeleteModal"></button>
<button id="linkAccWarningBtn" hidden="hidden" data-toggle="modal" data-target="#linkedWarningModal"></button>
<button class="btn btn-outline-danger ms-2" onclick="openDeleteModal(@Model.ProviderOrganizations.Count())">Delete</button>
<button id="deleteBtn" hidden="hidden" data-bs-toggle="modal" data-bs-target="#DeleteModal"></button>
<button id="linkAccWarningBtn" hidden="hidden" data-bs-toggle="modal" data-bs-target="#linkedWarningModal"></button>
</div>
</div>
}

View File

@ -11,23 +11,27 @@
<h1>Providers</h1>
<div class="row mb-2">
<div class="col">
<form class="form-inline mb-2" method="get">
<label class="sr-only" asp-for="Name">Name</label>
<input type="text" class="form-control mb-2 mr-2" placeholder="Name" asp-for="Name" name="name">
<label class="sr-only" asp-for="UserEmail">User email</label>
<input type="text" class="form-control mb-2 mr-2" placeholder="User email" asp-for="UserEmail" name="userEmail">
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button>
</form>
<form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
<div class="col-12">
<label class="visually-hidden" asp-for="Name">Name</label>
<input type="text" class="form-control" placeholder="Name" asp-for="Name" name="name">
</div>
<div class="col-12">
<label class="visually-hidden" asp-for="UserEmail">User email</label>
<input type="text" class="form-control" placeholder="User email" asp-for="UserEmail" name="userEmail">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary" title="Search">
<i class="fa fa-search"></i> Search
</button>
</div>
@if (canCreateProvider)
{
<div class="col-auto">
<div class="col-auto ms-auto">
<a asp-action="Create" class="btn btn-secondary">Create Provider</a>
</div>
}
</div>
</form>
<div class="table-responsive">
<table class="table table-striped table-hover">

View File

@ -24,9 +24,9 @@
<th>
@if (Model.Provider.Type == ProviderType.Reseller)
{
<div class="float-right text-nowrap">
<a asp-controller="Providers" asp-action="CreateOrganization" asp-route-providerId="@Model.Provider.Id" class="btn btn-sm btn-primary">New Organization</a>
<a asp-controller="Providers" asp-action="AddExistingOrganization" asp-route-id="@Model.Provider.Id" class="btn btn-sm btn-outline-primary">Add Existing Organization</a>
<div class="float-end text-nowrap">
<a asp-controller="Providers" asp-action="CreateOrganization" asp-route-providerId="@Model.Provider.Id" class="btn btn-sm btn-primary text-decoration-none">New Organization</a>
<a asp-controller="Providers" asp-action="AddExistingOrganization" asp-route-id="@Model.Provider.Id" class="btn btn-sm btn-outline-primary text-decoration-none">Add Existing Organization</a>
</div>
}
</th>
@ -51,16 +51,16 @@
@providerOrganization.Status
</td>
<td>
<div class="float-right">
<div class="float-end">
@if (canUnlinkFromProvider)
{
<a href="#" class="text-danger float-right" onclick="return unlinkProvider('@Model.Provider.Id', '@providerOrganization.Id');">
<a href="#" class="text-danger float-end" onclick="return unlinkProvider('@Model.Provider.Id', '@providerOrganization.Id');">
Unlink provider
</a>
}
@if (providerOrganization.Status == OrganizationStatusType.Pending)
{
<a href="#" class="float-right mr-3" onclick="return resendOwnerInvite('@providerOrganization.OrganizationId');">
<a href="#" class="float-end me-3" onclick="return resendOwnerInvite('@providerOrganization.OrganizationId');">
Resend invitation
</a>
}

View File

@ -26,8 +26,8 @@
<h2>General</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="Name"></label>
<div class="mb-3">
<label class="form-label" asp-for="Name"></label>
<input type="text" class="form-control" asp-for="Name" value="@Model.Name" required>
</div>
</div>
@ -37,17 +37,17 @@
{
<div class="row">
<div class="col-sm">
<div class="form-group">
<label>Client Owner Email</label>
<div class="mb-3">
<label class="form-label">Client Owner Email</label>
@if (!string.IsNullOrWhiteSpace(Model.Owners))
{
<input type="text" class="form-control" asp-for="Owners" readonly="readonly">
<input type="text" class="form-control" asp-for="Owners" readonly>
}
else
{
<input type="text" class="form-control" asp-for="Owners" required>
}
<label class="form-check-label small text-muted align-top">This user should be independent of the Provider. If the Provider is disassociated with the organization, this user will maintain ownership of the organization.</label>
<div class="form-text mt-0">This user should be independent of the Provider. If the Provider is disassociated with the organization, this user will maintain ownership of the organization.</div>
</div>
</div>
</div>
@ -66,8 +66,8 @@
<h2>Plan</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="PlanType"></label>
<div class="mb-3">
<label class="form-label" asp-for="PlanType"></label>
@{
var planTypes = Enum.GetValues<PlanType>()
.Where(p =>
@ -83,12 +83,12 @@
})
.ToList();
}
<select class="form-control" asp-for="PlanType" asp-items="planTypes" disabled='@(canEditPlan ? null : "disabled")'></select>
<select class="form-select" asp-for="PlanType" asp-items="planTypes" disabled='@(canEditPlan ? null : "disabled")'></select>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="Plan"></label>
<div class="mb-3">
<label class="form-label" asp-for="Plan"></label>
<input type="text" class="form-control" asp-for="Plan" required readonly='@(!canEditPlan)'>
</div>
</div>
@ -172,28 +172,28 @@
<h2>Password Manager Configuration</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="Seats"></label>
<div class="mb-3">
<label class="form-label" asp-for="Seats"></label>
<input type="number" class="form-control" asp-for="Seats" min="1" readonly='@(!canEditPlan)'>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="MaxCollections"></label>
<div class="mb-3">
<label class="form-label" asp-for="MaxCollections"></label>
<input type="number" class="form-control" asp-for="MaxCollections" min="1" readonly='@(!canEditPlan)'>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="MaxStorageGb"></label>
<div class="mb-3">
<label class="form-label" asp-for="MaxStorageGb"></label>
<input type="number" class="form-control" asp-for="MaxStorageGb" min="1" readonly='@(!canEditPlan)'>
</div>
</div>
</div>
<div class="row">
<div class="col-4">
<div class="form-group">
<label asp-for="MaxAutoscaleSeats"></label>
<div class="mb-3">
<label class="form-label" asp-for="MaxAutoscaleSeats"></label>
<input type="number" class="form-control" asp-for="MaxAutoscaleSeats" min="1" readonly='@(!canEditPlan)'>
</div>
</div>
@ -202,32 +202,32 @@
@if (canViewPlan)
{
<div id="organization-secrets-configuration" hidden="@(!Model.UseSecretsManager)">
<div id="organization-secrets-configuration" @(Model.UseSecretsManager ? null : "lass='d-none'")>
<h2>Secrets Manager Configuration</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="SmSeats"></label>
<div class="mb-3">
<label class="form-label" asp-for="SmSeats"></label>
<input type="number" class="form-control" asp-for="SmSeats" min="1" readonly='@(!canEditPlan)'>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="MaxAutoscaleSmSeats"></label>
<div class="mb-3">
<label class="form-label" asp-for="MaxAutoscaleSmSeats"></label>
<input type="number" class="form-control" asp-for="MaxAutoscaleSmSeats" min="1" readonly='@(!canEditPlan)'>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="SmServiceAccounts"></label>
<div class="mb-3">
<label class="form-label" asp-for="SmServiceAccounts"></label>
<input type="number" class="form-control" asp-for="SmServiceAccounts" min="1" readonly='@(!canEditPlan)'>
</div>
</div>
</div>
<div class="row">
<div class="col-4">
<div class="form-group">
<label asp-for="MaxAutoscaleSmServiceAccounts"></label>
<div class="mb-3">
<label class="form-label" asp-for="MaxAutoscaleSmServiceAccounts"></label>
<input type="number" class="form-control" asp-for="MaxAutoscaleSmServiceAccounts" min="1" readonly='@(!canEditPlan)'>
</div>
</div>
@ -240,14 +240,14 @@
<h2>Licensing</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="LicenseKey"></label>
<div class="mb-3">
<label class="form-label" asp-for="LicenseKey"></label>
<input type="text" class="form-control" asp-for="LicenseKey" readonly='@(!canEditLicensing)'>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="ExpirationDate"></label>
<div class="mb-3">
<label class="form-label" asp-for="ExpirationDate"></label>
<input type="datetime-local" class="form-control" asp-for="ExpirationDate" readonly='@(!canEditLicensing)' step="1">
</div>
</div>
@ -259,52 +259,46 @@
<h2>Billing</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="BillingEmail"></label>
<div class="mb-3">
<label class="form-label" asp-for="BillingEmail"></label>
<input type="email" class="form-control" asp-for="BillingEmail" readonly="readonly">
</div>
</div>
<div class="col-sm">
<div class="form-group">
<div class="form-group">
<label asp-for="Gateway"></label>
<select class="form-control" asp-for="Gateway" disabled='@(canEditBilling ? null : "disabled")'
<div class="mb-3">
<label class="form-label" asp-for="Gateway"></label>
<select class="form-select" asp-for="Gateway" disabled="@(!canEditBilling)"
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option>
</select>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="GatewayCustomerId"></label>
<div class="mb-3">
<label class="form-label" asp-for="GatewayCustomerId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'>
@if(canLaunchGateway)
{
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
<i class="fa fa-external-link"></i>
</button>
</div>
}
</div>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="GatewaySubscriptionId"></label>
<div class="mb-3">
<label class="form-label" asp-for="GatewaySubscriptionId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'>
@if (canLaunchGateway)
{
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
<i class="fa fa-external-link"></i>
</button>
</div>
}
</div>
</div>

View File

@ -3,8 +3,8 @@
ViewData["Title"] = "Login";
}
<div class="row justify-content-md-center">
<div class="col col-lg-6 col-md-8">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
@if(!string.IsNullOrWhiteSpace(Model.Success))
{
<div class="alert alert-success" role="alert">@Model.Success</div>
@ -19,12 +19,12 @@
<form asp-action="" method="post">
<input type="hidden" asp-for="ReturnUrl" />
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group">
<label asp-for="Email" class="sr-only">Email Address</label>
<div class="mb-3">
<label asp-for="Email" class="visually-hidden">Email Address</label>
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
required autofocus>
<span asp-validation-for="Email" class="invalid-feedback"></span>
<small class="form-text text-muted">We'll email you a secure login link.</small>
<div class="form-text">We'll email you a secure login link.</div>
</div>
<button class="btn btn-primary" type="submit">Continue</button>
</form>

View File

@ -25,22 +25,22 @@ bf82d3cf-0e21-4f39-b81b-ef52b2fc6a3a
</div>
<form method="post" asp-controller="MigrateProviders" asp-action="Post" class="mt-2">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<label asp-for="ProviderIds"></label>
<div class="mb-3">
<label class="form-label" asp-for="ProviderIds"></label>
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
</div>
<div class="form-group">
<input type="submit" value="Run" class="btn btn-primary mb-2"/>
<div class="mb-3">
<input type="submit" value="Run" class="btn btn-primary"/>
</div>
</form>
<form method="get" asp-controller="MigrateProviders" asp-action="Results" class="mt-2">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<label asp-for="ProviderIds"></label>
<div class="mb-3">
<label class="form-label" asp-for="ProviderIds"></label>
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
</div>
<div class="form-group">
<input type="submit" value="See Previous Results" class="btn btn-primary mb-2"/>
<div class="mb-3">
<input type="submit" value="See Previous Results" class="btn btn-primary"/>
</div>
</form>
</section>

View File

@ -1,4 +1,7 @@
@import "webfonts.scss";
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/mixins";
$primary: #175DDC;
$primary-accent: #1252A3;
@ -7,7 +10,8 @@ $info: #555555;
$warning: #bf7e16;
$danger: #dd4b39;
$theme-colors: ( "primary-accent": $primary-accent );
$theme-colors: map-merge($theme-colors, ("primary-accent": $primary-accent));
$font-family-sans-serif: 'Open Sans','Helvetica Neue',Helvetica,Arial,sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
$h1-font-size: 2rem;
@ -17,7 +21,7 @@ $h4-font-size: 1rem;
$h5-font-size: 1rem;
$h6-font-size: 1rem;
@import "bootstrap/scss/bootstrap.scss";
@import "bootstrap/scss/bootstrap";
h1 {
border-bottom: 1px solid $border-color;
@ -49,3 +53,11 @@ h3 {
.form-check-input {
margin-top: .45rem;
}
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}

View File

@ -105,7 +105,7 @@
<h3>SMTP</h3>
@if(!Bit.Core.Utilities.CoreHelpers.SettingHasValue(Model.GlobalSettings.Mail?.Smtp?.Host))
{
<p class="text-muted">Not configured</p>
<p class="text-body-secondary">Not configured</p>
}
else
{
@ -159,7 +159,7 @@ else
}
else
{
<span class="text-muted">Not configured</span>
<span class="text-body-secondary">Not configured</span>
}
</dd>
@ -171,7 +171,7 @@ else
}
else
{
<span class="text-muted">Not configured</span>
<span class="text-body-secondary">Not configured</span>
}
</dd>
@ -183,7 +183,7 @@ else
}
else
{
<span class="text-muted">Not configured</span>
<span class="text-body-secondary">Not configured</span>
}
</dd>
</dl>

View File

@ -37,12 +37,12 @@
<a class="navbar-brand" asp-controller="Home" asp-action="Index">
<i class="fa fa-lg fa-fw fa-shield"></i> Admin
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse"
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse"
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="navbar-nav mr-auto">
<ul class="navbar-nav me-auto mb-2 mb-md-0">
@if (SignInManager.IsSignedIn(User))
{
@if (canViewUsers)
@ -69,10 +69,10 @@
{
<li class="nav-item dropdown" active-controller="tools">
<a class="nav-link dropdown-toggle" href="#" id="toolsDropdown" role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Tools
</a>
<div class="dropdown-menu" aria-labelledby="toolsDropdown">
<ul class="dropdown-menu" aria-labelledby="toolsDropdown">
@if (canChargeBraintree)
{
<a class="dropdown-item" asp-controller="Tools" asp-action="ChargeBraintree">
@ -121,7 +121,7 @@
Migrate Providers
</a>
}
</div>
</ul>
</li>
}
}

View File

@ -10,30 +10,30 @@
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="row">
<div class="col-md">
<div class="form-group">
<label asp-for="UserId"></label>
<div class="mb-3">
<label asp-for="UserId" class="form-label"></label>
<input type="text" class="form-control" asp-for="UserId">
</div>
</div>
<div class="col-md">
<div class="form-group">
<label asp-for="OrganizationId"></label>
<div class="mb-3">
<label asp-for="OrganizationId" class="form-label"></label>
<input type="text" class="form-control" asp-for="OrganizationId">
</div>
</div>
</div>
<div class="row">
<div class="col-md">
<div class="form-group">
<label asp-for="Date"></label>
<div class="mb-3">
<label asp-for="Date" class="form-label"></label>
<input type="datetime-local" class="form-control" asp-for="Date" required>
</div>
</div>
<div class="col-md">
<div class="form-group">
<div class="form-group">
<label asp-for="Type"></label>
<select class="form-control" asp-for="Type" required
<div class="mb-3">
<label asp-for="Type" class="form-label"></label>
<select class="form-select" asp-for="Type" required
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.TransactionType>()"></select>
</div>
</div>
@ -41,24 +41,20 @@
</div>
<div class="row">
<div class="col-md">
<div class="form-group">
<label asp-for="Amount"></label>
<div class="input-group mb-3">
<div class="input-group-prepend">
<div class="mb-3">
<label asp-for="Amount" class="form-label"></label>
<div class="input-group">
<span class="input-group-text">$</span>
</div>
<input type="number" min="-1000000.00" max="1000000.00" step="0.01" class="form-control"
asp-for="Amount" required placeholder="ex. 10.00">
</div>
</div>
</div>
<div class="col-md">
<div class="form-group">
<label asp-for="RefundedAmount"></label>
<div class="input-group mb-3">
<div class="input-group-prepend">
<div class="mb-3">
<label asp-for="RefundedAmount" class="form-label"></label>
<div class="input-group">
<span class="input-group-text">$</span>
</div>
<input type="number" min="0.01" max="1000000.00" step="0.01" class="form-control"
asp-for="RefundedAmount" placeholder="ex. 10.00">
</div>
@ -69,41 +65,37 @@
<input type="checkbox" class="form-check-input" asp-for="Refunded">
<label class="form-check-label" asp-for="Refunded"></label>
</div>
<div class="form-group">
<label asp-for="Details"></label>
<div class="mb-3">
<label asp-for="Details" class="form-label"></label>
<input type="text" class="form-control" asp-for="Details" required>
</div>
<div class="row">
<div class="col-md">
<div class="form-group">
<div class="form-group">
<label asp-for="Gateway"></label>
<select class="form-control" asp-for="Gateway"
<div class="mb-3">
<label asp-for="Gateway" class="form-label"></label>
<select class="form-select" asp-for="Gateway"
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option>
</select>
</div>
</div>
</div>
<div class="col-md">
<div class="form-group">
<label asp-for="GatewayId"></label>
<div class="mb-3">
<label asp-for="GatewayId" class="form-label"></label>
<input type="text" class="form-control" asp-for="GatewayId">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<div class="form-group">
<label asp-for="PaymentMethod"></label>
<select class="form-control" asp-for="PaymentMethod"
<div class="mb-3">
<label asp-for="PaymentMethod" class="form-label"></label>
<select class="form-select" asp-for="PaymentMethod"
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.PaymentMethodType>()">
<option value="">--</option>
</select>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary mb-2">@action Transaction</button>
</form>

View File

@ -9,28 +9,28 @@
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="row">
<div class="col-md">
<div class="form-group">
<label asp-for="UserId"></label>
<div class="mb-3">
<label asp-for="UserId" class="form-label"></label>
<input type="text" class="form-control" asp-for="UserId">
</div>
</div>
<div class="col-md">
<div class="form-group">
<label asp-for="OrganizationId"></label>
<div class="mb-3">
<label asp-for="OrganizationId" class="form-label"></label>
<input type="text" class="form-control" asp-for="OrganizationId">
</div>
</div>
</div>
<div class="row">
<div class="col-md">
<div class="form-group">
<label asp-for="InstallationId"></label>
<div class="mb-3">
<label asp-for="InstallationId" class="form-label"></label>
<input type="text" class="form-control" asp-for="InstallationId">
</div>
</div>
<div class="col-md">
<div class="form-group">
<label asp-for="Version"></label>
<div class="mb-3">
<label asp-for="Version" class="form-label"></label>
<input type="number" class="form-control" asp-for="Version">
</div>
</div>

View File

@ -9,17 +9,17 @@
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="row">
<div class="col-md">
<div class="form-group">
<label asp-for="UserId"></label>
<div class="mb-3">
<label asp-for="UserId" class="form-label"></label>
<input type="text" class="form-control" asp-for="UserId">
</div>
</div>
<div class="col-md">
<div class="form-group">
<label asp-for="OrganizationId"></label>
<div class="mb-3">
<label asp-for="OrganizationId" class="form-label"></label>
<input type="text" class="form-control" asp-for="OrganizationId">
</div>
</div>
</div>
<button type="submit" class="btn btn-primary mb-2">Promote Admin</button>
<button type="submit" class="btn btn-primary">Promote Admin</button>
</form>

View File

@ -74,38 +74,38 @@
<div class="alert alert-success"></div>
}
<form method="post">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="row">
<div class="col-6">
<label asp-for="Filter.Status">Status</label>
<select asp-for="Filter.Status" name="filter.Status" class="form-control mr-2">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="Filter.Status">Status</label>
<select asp-for="Filter.Status" name="filter.Status" class="form-select">
<option asp-selected="Model.Filter.Status == null" value="all">All</option>
<option asp-selected='Model.Filter.Status == "active"' value="active">Active</option>
<option asp-selected='Model.Filter.Status == "unpaid"' value="unpaid">Unpaid</option>
</select>
</div>
<div class="col-6">
<label asp-for="Filter.CurrentPeriodEnd">Current Period End</label>
<div class="col-md-6">
<label class="form-label" asp-for="Filter.CurrentPeriodEnd">Current Period End</label>
<div class="input-group">
<div class="input-group-append">
<div class="input-group-text">
<span class="mr-1">
<input type="radio" class="mr-1" asp-for="Filter.CurrentPeriodEndRange" name="filter.CurrentPeriodEndRange" value="lt">Before
</span>
<input type="radio" asp-for="Filter.CurrentPeriodEndRange" name="filter.CurrentPeriodEndRange" value="gt">After
<div class="form-check form-check-inline mb-0">
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="lt" id="beforeRadio">
<label class="form-check-label me-2" for="beforeRadio">Before</label>
</div>
<div class="form-check form-check-inline mb-0">
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="gt" id="afterRadio">
<label class="form-check-label" for="afterRadio">After</label>
</div>
</div>
@{
var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty;
<input type="date" class="form-control" asp-for="Filter.CurrentPeriodEndDate" name="filter.CurrentPeriodEndDate" value="@date">
}
<input type="date" class="form-control" asp-for="Filter.CurrentPeriodEndDate" name="filter.CurrentPeriodEndDate" value="@date">
</div>
</div>
</div>
<div class="row mt-2">
<div class="col-6">
<label asp-for="Filter.Price">Price ID</label>
<select asp-for="Filter.Price" name="filter.Price" class="form-control mr-2">
<div class="col-md-6">
<label class="form-label" asp-for="Filter.Price">Price ID</label>
<select asp-for="Filter.Price" name="filter.Price" class="form-select">
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
@foreach (var price in Model.Prices)
{
@ -113,9 +113,9 @@
}
</select>
</div>
<div class="col-6">
<label asp-for="Filter.TestClock">Test Clock</label>
<select asp-for="Filter.TestClock" name="filter.TestClock" class="form-control mr-2">
<div class="col-md-6">
<label class="form-label" asp-for="Filter.TestClock">Test Clock</label>
<select asp-for="Filter.TestClock" name="filter.TestClock" class="form-select">
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
@foreach (var clock in Model.TestClocks)
{
@ -123,26 +123,32 @@
}
</select>
</div>
</div>
<div class="row col-12 d-flex justify-content-end my-3">
<button type="submit" class="btn btn-primary" title="Search" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Search"><i class="fa fa-search"></i> Search</button>
</div>
<hr/>
<input type="checkbox" class="d-none" name="filter.SelectAll" id="selectAllInput" asp-for="@Model.Filter.SelectAll">
<div class="text-center row d-flex justify-content-center">
<div class="col-12 text-end">
<button type="submit" class="btn btn-primary" title="Search" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Search">
<i class="fa fa-search"></i> Search
</button>
</div>
</div>
<hr/>
<input type="checkbox" class="d-none" name="filter.SelectAll" id="selectAllInput" asp-for="@Model.Filter.SelectAll">
<div class="text-center row d-flex justify-content-center">
<div id="selectAll" class="d-none col-8">
All @Model.Items.Count subscriptions on this page are selected.<br/>
<button type="button" id="selectAllElement" class="btn btn-link p-0 pb-1" onclick="onSelectAll()">Click here to select all subscriptions for this search.</button>
<span id="selectedAllConfirmation" class="d-none text-muted">✔ All subscriptions for this search are selected.</span><br/>
<div class="alert alert-warning" role="alert">Please be aware that bulk operations may take several minutes to complete.</div>
<span id="selectedAllConfirmation" class="d-none text-body-secondary">
<i class="fa fa-check"></i> All subscriptions for this search are selected.
</span>
<div class="alert alert-warning mt-2" role="alert">
Please be aware that bulk operations may take several minutes to complete.
</div>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover">
</div>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead>
<tr>
<th>
<div class="form-check form-check-inline">
<div class="form-check">
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)">
</div>
</th>
@ -191,7 +197,7 @@
@{
var i2 = i;
}
<input class="form-check-input row-check" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
<input class="form-check-input row-check mt-0" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
</div>
</td>
<td>
@ -214,9 +220,9 @@
}
</tbody>
</table>
</div>
<nav class="d-inline-flex">
<ul class="pagination">
</div>
<nav class="d-inline-flex align-items-center">
<ul class="pagination mb-0">
@if (!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
{
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore">
@ -257,25 +263,15 @@
</li>
}
</ul>
<span id="bulkActions" class="d-none ml-2">
<span class="d-inline-flex">
<button
type="submit"
class="btn btn-primary mr-1"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.Export">
<span id="bulkActions" class="d-none ms-3">
<span class="d-inline-flex gap-2">
<button type="submit" class="btn btn-primary" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Export">
Export
</button>
<button
type="submit"
class="btn btn-danger"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.BulkCancel">
<button type="submit" class="btn btn-danger" name="action" asp-for="Action" value="@StripeSubscriptionsAction.BulkCancel">
Bulk Cancel
</button>
</span>
</span>
</nav>
</nav>
</form>

View File

@ -27,20 +27,20 @@
</div>
</div>
<form method="post" enctype="multipart/form-data" asp-action="TaxRateUpload">
<div class="form-group">
<input type="file" name="file" />
<div class="mb-3">
<input type="file" class="form-control" name="file" />
</div>
<div class="form-group">
<input type="submit" value="Upload" class="btn btn-primary mb-2" />
<div class="mb-3">
<input type="submit" value="Upload" class="btn btn-primary" />
</div>
</form>
</section>
<hr/>
<hr class="my-4">
<h2>View &amp; Manage Tax Rates</h2>
<a class="btn btn-primary mb-2" asp-controller="Tools" asp-action="TaxRateAddEdit">Add a Rate</a>
<a class="btn btn-primary mb-3" asp-controller="Tools" asp-action="TaxRateAddEdit">Add a Rate</a>
<div class="table-responsive">
<table class="table table-striped table-hover">
<table class="table table-striped table-hover align-middle">
<thead>
<tr>
<th style="width: 190px;">Id</th>
@ -97,7 +97,7 @@
</table>
</div>
<nav>
<nav aria-label="Tax rates pagination">
<ul class="pagination">
@if(Model.PreviousPage.HasValue)
{

View File

@ -115,8 +115,8 @@
<h2>Premium</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="MaxStorageGb"></label>
<div class="mb-3">
<label asp-for="MaxStorageGb" class="form-label"></label>
<input type="number" class="form-control" asp-for="MaxStorageGb" min="1" readonly='@(!canEditPremium)'>
</div>
</div>
@ -131,14 +131,14 @@
<h2>Licensing</h2>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="LicenseKey"></label>
<div class="mb-3">
<label asp-for="LicenseKey" class="form-label"></label>
<input type="text" class="form-control" asp-for="LicenseKey" readonly='@(!canEditLicensing)'>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="PremiumExpirationDate"></label>
<div class="mb-3">
<label asp-for="PremiumExpirationDate" class="form-label"></label>
<input type="datetime-local" class="form-control" asp-for="PremiumExpirationDate" readonly='@(!canEditLicensing)'>
</div>
</div>
@ -149,44 +149,38 @@
<h2>Billing</h2>
<div class="row">
<div class="col-md">
<div class="form-group">
<div class="form-group">
<label asp-for="Gateway"></label>
<select class="form-control" asp-for="Gateway" disabled='@(canEditBilling ? null : "disabled")'
<div class="mb-3">
<label asp-for="Gateway" class="form-label"></label>
<select class="form-select" asp-for="Gateway" disabled='@(canEditBilling ? null : "disabled")'
asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option>
</select>
</div>
</div>
</div>
<div class="col-md">
<div class="form-group">
<label asp-for="GatewayCustomerId"></label>
<div class="mb-3">
<label asp-for="GatewayCustomerId" class="form-label"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId" readonly='@(!canEditBilling)'>
@if (canLaunchGateway)
{
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
<i class="fa fa-external-link"></i>
</button>
</div>
}
</div>
</div>
</div>
<div class="col-md">
<div class="form-group">
<label asp-for="GatewaySubscriptionId"></label>
<div class="mb-3">
<label asp-for="GatewaySubscriptionId" class="form-label"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId" readonly='@(!canEditBilling)'>
@if (canLaunchGateway)
{
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
<i class="fa fa-external-link"></i>
</button>
</div>
}
</div>
</div>
@ -196,10 +190,10 @@
</form>
<div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
<div class="ml-auto d-flex">
<div class="ms-auto d-flex">
@if (canUpgradePremium)
{
<button class="btn btn-secondary mr-2" type="button" id="upgrade-premium">
<button class="btn btn-secondary me-2" type="button" id="upgrade-premium">
Upgrade Premium
</button>
}

View File

@ -5,10 +5,16 @@
<h1>Users</h1>
<form class="form-inline mb-2" method="get">
<label class="sr-only" asp-for="Email">Email</label>
<input type="text" class="form-control mb-2 mr-2" placeholder="Email" asp-for="Email" name="email">
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button>
<form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
<div class="col-12">
<label class="visually-hidden" asp-for="Email">Email</label>
<input type="text" class="form-control" placeholder="Email" asp-for="Email" name="email">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary" title="Search">
<i class="fa fa-search"></i> Search
</button>
</div>
</form>
<div class="table-responsive">
@ -49,7 +55,7 @@
}
else
{
<i class="fa fa-star-o fa-lg fa-fw text-muted" title="Not Premium"></i>
<i class="fa fa-star-o fa-lg fa-fw text-body-secondary" title="Not Premium"></i>
}
@if (user.MaxStorageGb.HasValue && user.MaxStorageGb > 1)
{
@ -59,7 +65,7 @@
}
else
{
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted"
<i class="fa fa-plus-square-o fa-lg fa-fw text-body-secondary"
title="No Additional Storage">
</i>
}
@ -69,7 +75,7 @@
}
else
{
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i>
<i class="fa fa-times-circle-o fa-lg fa-fw text-body-secondary" title="Email Not Verified"></i>
}
@if (user.TwoFactorEnabled)
{
@ -77,7 +83,7 @@
}
else
{
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
<i class="fa fa-unlock fa-lg fa-fw text-body-secondary" title="2FA Not Enabled"></i>
}
</td>
</tr>

View File

@ -9,10 +9,9 @@
"version": "0.0.0",
"license": "GPL-3.0",
"dependencies": {
"bootstrap": "4.6.2",
"bootstrap": "5.3.3",
"font-awesome": "4.7.0",
"jquery": "3.7.1",
"popper.js": "1.16.1",
"toastr": "2.1.4"
},
"devDependencies": {
@ -385,6 +384,17 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@ -703,9 +713,9 @@
}
},
"node_modules/bootstrap": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz",
"integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==",
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
"integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
"funding": [
{
"type": "github",
@ -716,10 +726,8 @@
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"jquery": "1.9.1 - 3",
"popper.js": "^1.16.1"
"@popperjs/core": "^2.11.8"
}
},
"node_modules/braces": {
@ -1578,17 +1586,6 @@
"node": ">=8"
}
},
"node_modules/popper.js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/postcss": {
"version": "8.4.47",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",

View File

@ -8,10 +8,9 @@
"build": "webpack"
},
"dependencies": {
"bootstrap": "4.6.2",
"bootstrap": "5.3.3",
"font-awesome": "4.7.0",
"jquery": "3.7.1",
"popper.js": "1.16.1",
"toastr": "2.1.4"
},
"devDependencies": {

View File

@ -13,8 +13,6 @@ module.exports = {
entry: {
site: [
path.resolve(__dirname, paths.sassDir, "site.scss"),
"popper.js",
"bootstrap",
"jquery",
"font-awesome/css/font-awesome.css",

View File

@ -2,15 +2,16 @@
using Bit.Api.AdminConsole.Models.Response;
using Bit.Api.Models.Response;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Api.Vault.AuthorizationHandlers.Groups;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -89,11 +90,34 @@ public class GroupsController : Controller
}
[HttpGet("")]
public async Task<ListResponseModel<GroupDetailsResponseModel>> Get(Guid orgId)
public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroups(Guid orgId)
{
var authorized =
(await _authorizationService.AuthorizeAsync(User, GroupOperations.ReadAll(orgId))).Succeeded;
if (!authorized)
var authResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
if (!authResult.Succeeded)
{
throw new NotFoundException();
}
if (_featureService.IsEnabled(FeatureFlagKeys.SecureOrgGroupDetails))
{
var groups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
var responses = groups.Select(g => new GroupDetailsResponseModel(g, []));
return new ListResponseModel<GroupDetailsResponseModel>(responses);
}
var groupDetails = await _groupRepository.GetManyWithCollectionsByOrganizationIdAsync(orgId);
var detailResponses = groupDetails.Select(g => new GroupDetailsResponseModel(g.Item1, g.Item2));
return new ListResponseModel<GroupDetailsResponseModel>(detailResponses);
}
[HttpGet("details")]
public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroupDetails(Guid orgId)
{
var authResult = _featureService.IsEnabled(FeatureFlagKeys.SecureOrgGroupDetails)
? await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAllDetails)
: await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
if (!authResult.Succeeded)
{
throw new NotFoundException();
}

View File

@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;

View File

@ -289,9 +289,7 @@ public class OrganizationsController : Controller
throw new BadRequestException(string.Empty, "User verification failed.");
}
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (consolidatedBillingEnabled && organization.IsValidClient())
if (organization.IsValidClient())
{
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
@ -322,8 +320,7 @@ public class OrganizationsController : Controller
throw new BadRequestException("Invalid token.");
}
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (consolidatedBillingEnabled && organization.IsValidClient())
if (organization.IsValidClient())
{
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
if (provider.IsBillable())
@ -357,7 +354,7 @@ public class OrganizationsController : Controller
{
// Non-enterprise orgs should not be able to create or view an apikey of billing sync/scim key types
var plan = StaticStore.GetPlan(organization.PlanType);
if (plan.ProductTier != ProductTierType.Enterprise)
if (plan.ProductTier is not ProductTierType.Enterprise and not ProductTierType.Teams)
{
throw new NotFoundException();
}

View File

@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<UserSecretsId>bitwarden-Api</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>

View File

@ -4,15 +4,14 @@ using Bit.Api.Auth.Models.Response.TwoFactor;
using Bit.Api.Models.Request;
using Bit.Api.Models.Response;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Utilities;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Fido2NetLib;
@ -29,11 +28,10 @@ public class TwoFactorController : Controller
private readonly IUserService _userService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService;
private readonly GlobalSettings _globalSettings;
private readonly UserManager<User> _userManager;
private readonly ICurrentContext _currentContext;
private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand;
private readonly IFeatureService _featureService;
private readonly IDuoUniversalTokenService _duoUniversalTokenService;
private readonly IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> _twoFactorAuthenticatorDataProtector;
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmailTwoFactorSessionDataProtector;
@ -41,22 +39,20 @@ public class TwoFactorController : Controller
IUserService userService,
IOrganizationRepository organizationRepository,
IOrganizationService organizationService,
GlobalSettings globalSettings,
UserManager<User> userManager,
ICurrentContext currentContext,
IVerifyAuthRequestCommand verifyAuthRequestCommand,
IFeatureService featureService,
IDuoUniversalTokenService duoUniversalConfigService,
IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> twoFactorAuthenticatorDataProtector,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmailTwoFactorSessionDataProtector)
{
_userService = userService;
_organizationRepository = organizationRepository;
_organizationService = organizationService;
_globalSettings = globalSettings;
_userManager = userManager;
_currentContext = currentContext;
_verifyAuthRequestCommand = verifyAuthRequestCommand;
_featureService = featureService;
_duoUniversalTokenService = duoUniversalConfigService;
_twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector;
_ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector;
}
@ -184,21 +180,7 @@ public class TwoFactorController : Controller
public async Task<TwoFactorDuoResponseModel> PutDuo([FromBody] UpdateTwoFactorDuoRequestModel model)
{
var user = await CheckAsync(model, true);
try
{
// for backwards compatibility - will be removed with PM-8107
DuoApi duoApi = null;
if (model.ClientId != null && model.ClientSecret != null)
{
duoApi = new DuoApi(model.ClientId, model.ClientSecret, model.Host);
}
else
{
duoApi = new DuoApi(model.IntegrationKey, model.SecretKey, model.Host);
}
await duoApi.JSONApiCall("GET", "/auth/v2/check");
}
catch (DuoException)
if (!await _duoUniversalTokenService.ValidateDuoConfiguration(model.ClientSecret, model.ClientId, model.Host))
{
throw new BadRequestException(
"Duo configuration settings are not valid. Please re-check the Duo Admin panel.");
@ -241,21 +223,7 @@ public class TwoFactorController : Controller
}
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid) ?? throw new NotFoundException();
try
{
// for backwards compatibility - will be removed with PM-8107
DuoApi duoApi = null;
if (model.ClientId != null && model.ClientSecret != null)
{
duoApi = new DuoApi(model.ClientId, model.ClientSecret, model.Host);
}
else
{
duoApi = new DuoApi(model.IntegrationKey, model.SecretKey, model.Host);
}
await duoApi.JSONApiCall("GET", "/auth/v2/check");
}
catch (DuoException)
if (!await _duoUniversalTokenService.ValidateDuoConfiguration(model.ClientId, model.ClientSecret, model.Host))
{
throw new BadRequestException(
"Duo configuration settings are not valid. Please re-check the Duo Admin panel.");

View File

@ -2,8 +2,8 @@
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Utilities;
using Bit.Core.Entities;
using Fido2NetLib;
@ -43,21 +43,16 @@ public class UpdateTwoFactorAuthenticatorRequestModel : SecretVerificationReques
public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IValidatableObject
{
/*
To support both v2 and v4 we need to remove the required annotation from the properties.
todo - the required annotation will be added back in PM-8107.
String lengths based on Duo's documentation
https://github.com/duosecurity/duo_universal_csharp/blob/main/DuoUniversal/Client.cs
*/
[StringLength(50)]
public string ClientId { get; set; }
[StringLength(50)]
public string ClientSecret { get; set; }
//todo - will remove SKey and IKey with PM-8107
[StringLength(50)]
public string IntegrationKey { get; set; }
//todo - will remove SKey and IKey with PM-8107
[StringLength(50)]
public string SecretKey { get; set; }
[Required]
[StringLength(50)]
[StringLength(20, MinimumLength = 20, ErrorMessage = "Client Id must be exactly 20 characters.")]
public string ClientId { get; set; }
[Required]
[StringLength(40, MinimumLength = 40, ErrorMessage = "Client Secret must be exactly 40 characters.")]
public string ClientSecret { get; set; }
[Required]
public string Host { get; set; }
public User ToUser(User existingUser)
@ -65,22 +60,17 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
var providers = existingUser.GetTwoFactorProviders();
if (providers == null)
{
providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
providers = [];
}
else if (providers.ContainsKey(TwoFactorProviderType.Duo))
{
providers.Remove(TwoFactorProviderType.Duo);
}
Temporary_SyncDuoParams();
providers.Add(TwoFactorProviderType.Duo, new TwoFactorProvider
{
MetaData = new Dictionary<string, object>
{
//todo - will remove SKey and IKey with PM-8107
["SKey"] = SecretKey,
["IKey"] = IntegrationKey,
["ClientSecret"] = ClientSecret,
["ClientId"] = ClientId,
["Host"] = Host
@ -96,22 +86,17 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
var providers = existingOrg.GetTwoFactorProviders();
if (providers == null)
{
providers = new Dictionary<TwoFactorProviderType, TwoFactorProvider>();
providers = [];
}
else if (providers.ContainsKey(TwoFactorProviderType.OrganizationDuo))
{
providers.Remove(TwoFactorProviderType.OrganizationDuo);
}
Temporary_SyncDuoParams();
providers.Add(TwoFactorProviderType.OrganizationDuo, new TwoFactorProvider
{
MetaData = new Dictionary<string, object>
{
//todo - will remove SKey and IKey with PM-8107
["SKey"] = SecretKey,
["IKey"] = IntegrationKey,
["ClientSecret"] = ClientSecret,
["ClientId"] = ClientId,
["Host"] = Host
@ -124,34 +109,22 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV
public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!DuoApi.ValidHost(Host))
var results = new List<ValidationResult>();
if (string.IsNullOrWhiteSpace(ClientId))
{
yield return new ValidationResult("Host is invalid.", [nameof(Host)]);
}
if (string.IsNullOrWhiteSpace(ClientSecret) && string.IsNullOrWhiteSpace(ClientId) &&
string.IsNullOrWhiteSpace(SecretKey) && string.IsNullOrWhiteSpace(IntegrationKey))
{
yield return new ValidationResult("Neither v2 or v4 values are valid.", [nameof(IntegrationKey), nameof(SecretKey), nameof(ClientSecret), nameof(ClientId)]);
}
results.Add(new ValidationResult("ClientId is required.", [nameof(ClientId)]));
}
/*
use this method to ensure that both v2 params and v4 params are in sync
todo will be removed in pm-8107
*/
private void Temporary_SyncDuoParams()
if (string.IsNullOrWhiteSpace(ClientSecret))
{
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret
if (!string.IsNullOrWhiteSpace(ClientSecret) && !string.IsNullOrWhiteSpace(ClientId))
{
SecretKey = ClientSecret;
IntegrationKey = ClientId;
results.Add(new ValidationResult("ClientSecret is required.", [nameof(ClientSecret)]));
}
else if (!string.IsNullOrWhiteSpace(SecretKey) && !string.IsNullOrWhiteSpace(IntegrationKey))
if (string.IsNullOrWhiteSpace(Host) || !DuoUniversalTokenService.ValidDuoHost(Host))
{
ClientSecret = SecretKey;
ClientId = IntegrationKey;
results.Add(new ValidationResult("Host is invalid.", [nameof(Host)]));
}
return results;
}
}

View File

@ -13,37 +13,26 @@ public class TwoFactorDuoResponseModel : ResponseModel
public TwoFactorDuoResponseModel(User user)
: base(ResponseObj)
{
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
ArgumentNullException.ThrowIfNull(user);
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
Build(provider);
}
public TwoFactorDuoResponseModel(Organization org)
public TwoFactorDuoResponseModel(Organization organization)
: base(ResponseObj)
{
if (org == null)
{
throw new ArgumentNullException(nameof(org));
}
ArgumentNullException.ThrowIfNull(organization);
var provider = org.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
Build(provider);
}
public bool Enabled { get; set; }
public string Host { get; set; }
//TODO - will remove SecretKey with PM-8107
public string SecretKey { get; set; }
//TODO - will remove IntegrationKey with PM-8107
public string IntegrationKey { get; set; }
public string ClientSecret { get; set; }
public string ClientId { get; set; }
// updated build to assist in the EDD migration for the Duo 2FA provider
private void Build(TwoFactorProvider provider)
{
if (provider?.MetaData != null && provider.MetaData.Count > 0)
@ -54,36 +43,13 @@ public class TwoFactorDuoResponseModel : ResponseModel
{
Host = (string)host;
}
//todo - will remove SKey and IKey with PM-8107
// check Skey and IKey first if they exist
if (provider.MetaData.TryGetValue("SKey", out var sKey))
{
ClientSecret = MaskKey((string)sKey);
SecretKey = MaskKey((string)sKey);
}
if (provider.MetaData.TryGetValue("IKey", out var iKey))
{
IntegrationKey = (string)iKey;
ClientId = (string)iKey;
}
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret
if (provider.MetaData.TryGetValue("ClientSecret", out var clientSecret))
{
if (!string.IsNullOrWhiteSpace((string)clientSecret))
{
ClientSecret = MaskKey((string)clientSecret);
SecretKey = MaskKey((string)clientSecret);
}
ClientSecret = MaskSecret((string)clientSecret);
}
if (provider.MetaData.TryGetValue("ClientId", out var clientId))
{
if (!string.IsNullOrWhiteSpace((string)clientId))
{
ClientId = (string)clientId;
IntegrationKey = (string)clientId;
}
}
}
else
@ -92,30 +58,7 @@ public class TwoFactorDuoResponseModel : ResponseModel
}
}
/*
use this method to ensure that both v2 params and v4 params are in sync
todo will be removed in pm-8107
*/
private void Temporary_SyncDuoParams()
{
// Even if IKey and SKey exist prioritize v4 params ClientId and ClientSecret
if (!string.IsNullOrWhiteSpace(ClientSecret) && !string.IsNullOrWhiteSpace(ClientId))
{
SecretKey = ClientSecret;
IntegrationKey = ClientId;
}
else if (!string.IsNullOrWhiteSpace(SecretKey) && !string.IsNullOrWhiteSpace(IntegrationKey))
{
ClientSecret = SecretKey;
ClientId = IntegrationKey;
}
else
{
throw new InvalidDataException("Invalid Duo parameters.");
}
}
private static string MaskKey(string key)
private static string MaskSecret(string key)
{
if (string.IsNullOrWhiteSpace(key) || key.Length <= 6)
{

View File

@ -22,9 +22,9 @@ public abstract class BaseBillingController : Controller
new ErrorResponseModel(message),
statusCode: StatusCodes.Status500InternalServerError);
public static JsonHttpResult<ErrorResponseModel> Unauthorized() =>
public static JsonHttpResult<ErrorResponseModel> Unauthorized(string message = "Unauthorized.") =>
TypedResults.Json(
new ErrorResponseModel("Unauthorized."),
new ErrorResponseModel(message),
statusCode: StatusCodes.Status401Unauthorized);
}
}

View File

@ -1,5 +1,4 @@
using Bit.Core;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Extensions;
using Bit.Core.Context;
@ -9,7 +8,6 @@ namespace Bit.Api.Billing.Controllers;
public abstract class BaseProviderController(
ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger,
IProviderRepository providerRepository,
IUserService userService) : BaseBillingController
@ -26,15 +24,6 @@ public abstract class BaseProviderController(
Guid providerId,
Func<Guid, bool> checkAuthorization)
{
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
logger.LogError(
"Cannot run Consolidated Billing operation for provider ({ProviderID}) while feature flag is disabled",
providerId);
return (null, Error.NotFound());
}
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)

View File

@ -19,14 +19,13 @@ namespace Bit.Api.Billing.Controllers;
[Authorize("Application")]
public class ProviderBillingController(
ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger,
IProviderBillingService providerBillingService,
IProviderPlanRepository providerPlanRepository,
IProviderRepository providerRepository,
ISubscriberService subscriberService,
IStripeAdapter stripeAdapter,
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService)
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
{
[HttpGet("invoices")]
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid providerId)

View File

@ -14,14 +14,13 @@ namespace Bit.Api.Billing.Controllers;
[Route("providers/{providerId:guid}/clients")]
public class ProviderClientsController(
ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger,
IOrganizationRepository organizationRepository,
IProviderBillingService providerBillingService,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderRepository providerRepository,
IProviderService providerService,
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService)
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
{
[HttpPost]
public async Task<IResult> CreateAsync(
@ -102,15 +101,27 @@ public class ProviderClientsController(
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (clientOrganization.Seats != requestBody.AssignedSeats)
if (clientOrganization is not { Status: OrganizationStatusType.Managed })
{
await providerBillingService.AssignSeatsToClientOrganization(
provider,
clientOrganization,
requestBody.AssignedSeats);
return Error.ServerError();
}
var seatAdjustment = requestBody.AssignedSeats - (clientOrganization.Seats ?? 0);
var seatAdjustmentResultsInPurchase = await providerBillingService.SeatAdjustmentResultsInPurchase(
provider,
clientOrganization.PlanType,
seatAdjustment);
if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id))
{
return Error.Unauthorized("Service users cannot purchase additional seats.");
}
await providerBillingService.ScaleSeats(provider, clientOrganization.PlanType, seatAdjustment);
clientOrganization.Name = requestBody.Name;
clientOrganization.Seats = requestBody.AssignedSeats;
await organizationRepository.ReplaceAsync(clientOrganization);

View File

@ -6,12 +6,14 @@ public record OrganizationMetadataResponse(
bool IsEligibleForSelfHost,
bool IsManaged,
bool IsOnSecretsManagerStandalone,
bool IsSubscriptionUnpaid)
bool IsSubscriptionUnpaid,
bool HasSubscription)
{
public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
=> new(
metadata.IsEligibleForSelfHost,
metadata.IsManaged,
metadata.IsOnSecretsManagerStandalone,
metadata.IsSubscriptionUnpaid);
metadata.IsSubscriptionUnpaid,
metadata.HasSubscription);
}

View File

@ -23,7 +23,6 @@ using Microsoft.OpenApi.Models;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.UserFeatures;
using Bit.Core.Entities;
using Bit.Core.Billing.Extensions;
@ -32,10 +31,10 @@ using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Tools.ReportFeatures;
#if !OSS
using Bit.Commercial.Core.SecretsManager;
using Bit.Commercial.Core.Utilities;

View File

@ -1,5 +1,5 @@
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Api.Vault.AuthorizationHandlers.Groups;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
using Bit.Core.IdentityServer;
using Bit.Core.Settings;
using Bit.Core.Utilities;

View File

@ -1,62 +0,0 @@
#nullable enable
using Bit.Core.Context;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.Vault.AuthorizationHandlers.Groups;
/// <summary>
/// Handles authorization logic for Group operations.
/// This uses new logic implemented in the Flexible Collections initiative.
/// </summary>
public class GroupAuthorizationHandler : AuthorizationHandler<GroupOperationRequirement>
{
private readonly ICurrentContext _currentContext;
public GroupAuthorizationHandler(ICurrentContext currentContext)
{
_currentContext = currentContext;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
GroupOperationRequirement requirement)
{
// Acting user is not authenticated, fail
if (!_currentContext.UserId.HasValue)
{
context.Fail();
return;
}
if (requirement.OrganizationId == default)
{
context.Fail();
return;
}
var org = _currentContext.GetOrganization(requirement.OrganizationId);
switch (requirement)
{
case not null when requirement.Name == nameof(GroupOperations.ReadAll):
await CanReadAllAsync(context, requirement, org);
break;
}
}
private async Task CanReadAllAsync(AuthorizationHandlerContext context, GroupOperationRequirement requirement,
CurrentContextOrganization? org)
{
// All users of an organization can read all groups belonging to the organization for collection access management
if (org is not null)
{
context.Succeed(requirement);
return;
}
// Allow provider users to read all groups if they are a provider for the target organization
if (await _currentContext.ProviderUserForOrgAsync(requirement.OrganizationId))
{
context.Succeed(requirement);
}
}
}

View File

@ -1,22 +0,0 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Api.Vault.AuthorizationHandlers.Groups;
public class GroupOperationRequirement : OperationAuthorizationRequirement
{
public Guid OrganizationId { get; init; }
public GroupOperationRequirement(string name, Guid organizationId)
{
Name = name;
OrganizationId = organizationId;
}
}
public static class GroupOperations
{
public static GroupOperationRequirement ReadAll(Guid organizationId)
{
return new GroupOperationRequirement(nameof(ReadAll), organizationId);
}
}

View File

@ -1,7 +1,6 @@
using Bit.Billing.Constants;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Repositories;
using Bit.Core.Enums;
using Bit.Core.Utilities;
@ -42,36 +41,26 @@ public class ProviderEventService(
case HandledStripeWebhook.InvoiceCreated:
{
var clients =
(await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId))
.Where(providerOrganization => providerOrganization.Status == OrganizationStatusType.Managed);
await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId);
var providerPlans = await providerPlanRepository.GetByProviderId(parsedProviderId);
var enterpriseProviderPlan =
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
var invoiceItems = new List<ProviderInvoiceItem>();
var teamsProviderPlan =
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured() ||
teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
foreach (var client in clients)
{
logger.LogError("Provider {ProviderID} is missing or has misconfigured provider plans", parsedProviderId);
throw new Exception("Cannot record invoice line items for Provider with missing or misconfigured provider plans");
if (client.Status != OrganizationStatusType.Managed)
{
continue;
}
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
var plan = StaticStore.Plans.Single(x => x.Name == client.Plan && providerPlans.Any(y => y.PlanType == x.Type));
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
var discountedEnterpriseSeatPrice = enterprisePlan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
var discountedTeamsSeatPrice = teamsPlan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
var invoiceItems = clients.Select(client => new ProviderInvoiceItem
invoiceItems.Add(new ProviderInvoiceItem
{
ProviderId = parsedProviderId,
InvoiceId = invoice.Id,
@ -81,39 +70,23 @@ public class ProviderEventService(
PlanName = client.Plan,
AssignedSeats = client.Seats ?? 0,
UsedSeats = client.OccupiedSeats ?? 0,
Total = client.Plan == enterprisePlan.Name
? (client.Seats ?? 0) * discountedEnterpriseSeatPrice
: (client.Seats ?? 0) * discountedTeamsSeatPrice
}).ToList();
if (enterpriseProviderPlan.PurchasedSeats is null or 0)
{
var enterpriseClientSeats = invoiceItems
.Where(item => item.PlanName == enterprisePlan.Name)
.Sum(item => item.AssignedSeats);
var unassignedEnterpriseSeats = enterpriseProviderPlan.SeatMinimum - enterpriseClientSeats ?? 0;
invoiceItems.Add(new ProviderInvoiceItem
{
ProviderId = parsedProviderId,
InvoiceId = invoice.Id,
InvoiceNumber = invoice.Number,
ClientName = "Unassigned seats",
PlanName = enterprisePlan.Name,
AssignedSeats = unassignedEnterpriseSeats,
UsedSeats = 0,
Total = unassignedEnterpriseSeats * discountedEnterpriseSeatPrice
Total = (client.Seats ?? 0) * discountedSeatPrice
});
}
if (teamsProviderPlan.PurchasedSeats is null or 0)
foreach (var providerPlan in providerPlans.Where(x => x.PurchasedSeats is null or 0))
{
var teamsClientSeats = invoiceItems
.Where(item => item.PlanName == teamsPlan.Name)
var plan = StaticStore.GetPlan(providerPlan.PlanType);
var clientSeats = invoiceItems
.Where(item => item.PlanName == plan.Name)
.Sum(item => item.AssignedSeats);
var unassignedTeamsSeats = teamsProviderPlan.SeatMinimum - teamsClientSeats ?? 0;
var unassignedSeats = providerPlan.SeatMinimum - clientSeats ?? 0;
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
invoiceItems.Add(new ProviderInvoiceItem
{
@ -121,10 +94,10 @@ public class ProviderEventService(
InvoiceId = invoice.Id,
InvoiceNumber = invoice.Number,
ClientName = "Unassigned seats",
PlanName = teamsPlan.Name,
AssignedSeats = unassignedTeamsSeats,
PlanName = plan.Name,
AssignedSeats = unassignedSeats,
UsedSeats = 0,
Total = unassignedTeamsSeats * discountedTeamsSeatPrice
Total = unassignedSeats * discountedSeatPrice
});
}

View File

@ -13,7 +13,7 @@
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
required autofocus>
<span asp-validation-for="Email" class="invalid-feedback"></span>
<small class="form-text text-muted">We'll email you a secure login link.</small>
<small class="form-text text-body-secondary">We'll email you a secure login link.</small>
</div>
<button class="btn btn-primary btn-block" type="submit">Continue</button>
</form>

View File

@ -0,0 +1,43 @@
#nullable enable
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.Context;
using Bit.Core.Enums;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
public class GroupAuthorizationHandler(ICurrentContext currentContext)
: AuthorizationHandler<GroupOperationRequirement, OrganizationScope>
{
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
GroupOperationRequirement requirement, OrganizationScope organizationScope)
{
var authorized = requirement switch
{
not null when requirement.Name == nameof(GroupOperations.ReadAll) =>
await CanReadAllAsync(organizationScope),
not null when requirement.Name == nameof(GroupOperations.ReadAllDetails) =>
await CanViewGroupDetailsAsync(organizationScope),
_ => false
};
if (requirement is not null && authorized)
{
context.Succeed(requirement);
}
}
private async Task<bool> CanReadAllAsync(OrganizationScope organizationScope) =>
currentContext.GetOrganization(organizationScope) is not null
|| await currentContext.ProviderUserForOrgAsync(organizationScope);
private async Task<bool> CanViewGroupDetailsAsync(OrganizationScope organizationScope) =>
currentContext.GetOrganization(organizationScope) is
{ Type: OrganizationUserType.Owner } or
{ Type: OrganizationUserType.Admin } or
{
Permissions: { ManageGroups: true } or
{ ManageUsers: true }
} ||
await currentContext.ProviderUserForOrgAsync(organizationScope);
}

View File

@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
public class GroupOperationRequirement : OperationAuthorizationRequirement
{
public GroupOperationRequirement(string name)
{
Name = name;
}
}
public static class GroupOperations
{
public static readonly GroupOperationRequirement ReadAll = new(nameof(ReadAll));
public static readonly GroupOperationRequirement ReadAllDetails = new(nameof(ReadAllDetails));
}

View File

@ -1,4 +1,5 @@
#nullable enable
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.Context;
using Bit.Core.Enums;
using Microsoft.AspNetCore.Authorization;

View File

@ -1,5 +1,5 @@
using Bit.Core.Context;
using Bit.Core.Services;
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.Context;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
@ -7,14 +7,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authoriza
public class OrganizationUserUserMiniDetailsAuthorizationHandler :
AuthorizationHandler<OrganizationUserUserMiniDetailsOperationRequirement, OrganizationScope>
{
private readonly IApplicationCacheService _applicationCacheService;
private readonly ICurrentContext _currentContext;
public OrganizationUserUserMiniDetailsAuthorizationHandler(
IApplicationCacheService applicationCacheService,
ICurrentContext currentContext)
public OrganizationUserUserMiniDetailsAuthorizationHandler(ICurrentContext currentContext)
{
_applicationCacheService = applicationCacheService;
_currentContext = currentContext;
}

View File

@ -1,6 +1,6 @@
#nullable enable
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
/// <summary>
/// A typed wrapper for an organization Guid. This is used for authorization checks

View File

@ -15,6 +15,7 @@ using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
@ -444,13 +445,6 @@ public class OrganizationService : IOrganizationService
public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup)
{
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (!consolidatedBillingEnabled)
{
throw new InvalidOperationException($"{nameof(SignupClientAsync)} is only for use within Consolidated Billing");
}
var plan = StaticStore.GetPlan(signup.Plan);
ValidatePlan(plan, signup.AdditionalSeats, "Password Manager");
@ -1443,10 +1437,7 @@ public class OrganizationService : IOrganizationService
if (provider is { Enabled: true })
{
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (consolidatedBillingEnabled && provider.Type == ProviderType.Msp &&
provider.Status == ProviderStatusType.Billable)
if (provider.IsBillable())
{
return (false, "Seat limit has been reached. Please contact your provider to add more seats.");
}

View File

@ -1,86 +0,0 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Utilities.Duo;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Settings;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.Identity;
public class DuoWebTokenProvider : IUserTwoFactorTokenProvider<User>
{
private readonly IServiceProvider _serviceProvider;
private readonly GlobalSettings _globalSettings;
public DuoWebTokenProvider(
IServiceProvider serviceProvider,
GlobalSettings globalSettings)
{
_serviceProvider = serviceProvider;
_globalSettings = globalSettings;
}
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
{
return false;
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
if (!HasProperMetaData(provider))
{
return false;
}
return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Duo, user);
}
public async Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
{
return null;
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
if (!HasProperMetaData(provider))
{
return null;
}
var signatureRequest = DuoWeb.SignRequest((string)provider.MetaData["IKey"],
(string)provider.MetaData["SKey"], _globalSettings.Duo.AKey, user.Email);
return signatureRequest;
}
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
{
return false;
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
if (!HasProperMetaData(provider))
{
return false;
}
var response = DuoWeb.VerifyResponse((string)provider.MetaData["IKey"], (string)provider.MetaData["SKey"],
_globalSettings.Duo.AKey, token);
return response == user.Email;
}
private bool HasProperMetaData(TwoFactorProvider provider)
{
return provider?.MetaData != null && provider.MetaData.ContainsKey("IKey") &&
provider.MetaData.ContainsKey("SKey") && provider.MetaData.ContainsKey("Host");
}
}

View File

@ -1,76 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Utilities.Duo;
using Bit.Core.Entities;
using Bit.Core.Settings;
namespace Bit.Core.Auth.Identity;
public interface IOrganizationDuoWebTokenProvider : IOrganizationTwoFactorTokenProvider { }
public class OrganizationDuoWebTokenProvider : IOrganizationDuoWebTokenProvider
{
private readonly GlobalSettings _globalSettings;
public OrganizationDuoWebTokenProvider(GlobalSettings globalSettings)
{
_globalSettings = globalSettings;
}
public Task<bool> CanGenerateTwoFactorTokenAsync(Organization organization)
{
if (organization == null || !organization.Enabled || !organization.Use2fa)
{
return Task.FromResult(false);
}
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
var canGenerate = organization.TwoFactorProviderIsEnabled(TwoFactorProviderType.OrganizationDuo)
&& HasProperMetaData(provider);
return Task.FromResult(canGenerate);
}
public Task<string> GenerateAsync(Organization organization, User user)
{
if (organization == null || !organization.Enabled || !organization.Use2fa)
{
return Task.FromResult<string>(null);
}
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
if (!HasProperMetaData(provider))
{
return Task.FromResult<string>(null);
}
var signatureRequest = DuoWeb.SignRequest(provider.MetaData["IKey"].ToString(),
provider.MetaData["SKey"].ToString(), _globalSettings.Duo.AKey, user.Email);
return Task.FromResult(signatureRequest);
}
public Task<bool> ValidateAsync(string token, Organization organization, User user)
{
if (organization == null || !organization.Enabled || !organization.Use2fa)
{
return Task.FromResult(false);
}
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
if (!HasProperMetaData(provider))
{
return Task.FromResult(false);
}
var response = DuoWeb.VerifyResponse(provider.MetaData["IKey"].ToString(),
provider.MetaData["SKey"].ToString(), _globalSettings.Duo.AKey, token);
return Task.FromResult(response == user.Email);
}
private bool HasProperMetaData(TwoFactorProvider provider)
{
return provider?.MetaData != null && provider.MetaData.ContainsKey("IKey") &&
provider.MetaData.ContainsKey("SKey") && provider.MetaData.ContainsKey("Host");
}
}

View File

@ -1,172 +0,0 @@
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Microsoft.Extensions.Logging;
using Duo = DuoUniversal;
namespace Bit.Core.Auth.Identity;
/*
PM-5156 addresses tech debt
Interface to allow for DI, will end up being removed as part of the removal of the old Duo SDK v2 flows.
This service is to support SDK v4 flows for Duo. At some time in the future we will need
to combine this service with the DuoWebTokenProvider and OrganizationDuoWebTokenProvider to support SDK v4.
*/
public interface ITemporaryDuoWebV4SDKService
{
Task<string> GenerateAsync(TwoFactorProvider provider, User user);
Task<bool> ValidateAsync(string token, TwoFactorProvider provider, User user);
}
public class TemporaryDuoWebV4SDKService : ITemporaryDuoWebV4SDKService
{
private readonly ICurrentContext _currentContext;
private readonly GlobalSettings _globalSettings;
private readonly IDataProtectorTokenFactory<DuoUserStateTokenable> _tokenDataFactory;
private readonly ILogger<TemporaryDuoWebV4SDKService> _logger;
/// <summary>
/// Constructor for the DuoUniversalPromptService. Used to supplement v2 implementation of Duo with v4 SDK
/// </summary>
/// <param name="currentContext">used to fetch initiating Client</param>
/// <param name="globalSettings">used to fetch vault URL for Redirect URL</param>
public TemporaryDuoWebV4SDKService(
ICurrentContext currentContext,
GlobalSettings globalSettings,
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
ILogger<TemporaryDuoWebV4SDKService> logger)
{
_currentContext = currentContext;
_globalSettings = globalSettings;
_tokenDataFactory = tokenDataFactory;
_logger = logger;
}
/// <summary>
/// Provider agnostic (either Duo or OrganizationDuo) method to generate a Duo Auth URL
/// </summary>
/// <param name="provider">Either Duo or OrganizationDuo</param>
/// <param name="user">self</param>
/// <returns>AuthUrl for DUO SDK v4</returns>
public async Task<string> GenerateAsync(TwoFactorProvider provider, User user)
{
if (!HasProperMetaData(provider))
{
if (!HasProperMetaData_SDKV2(provider))
{
return null;
}
}
var duoClient = await BuildDuoClientAsync(provider);
if (duoClient == null)
{
return null;
}
var state = _tokenDataFactory.Protect(new DuoUserStateTokenable(user));
var authUrl = duoClient.GenerateAuthUri(user.Email, state);
return authUrl;
}
/// <summary>
/// Validates Duo SDK v4 response
/// </summary>
/// <param name="token">response form Duo</param>
/// <param name="provider">TwoFactorProviderType Duo or OrganizationDuo</param>
/// <param name="user">self</param>
/// <returns>true or false depending on result of verification</returns>
public async Task<bool> ValidateAsync(string token, TwoFactorProvider provider, User user)
{
if (!HasProperMetaData(provider))
{
if (!HasProperMetaData_SDKV2(provider))
{
return false;
}
}
var duoClient = await BuildDuoClientAsync(provider);
if (duoClient == null)
{
return false;
}
var parts = token.Split("|");
var authCode = parts[0];
var state = parts[1];
_tokenDataFactory.TryUnprotect(state, out var tokenable);
if (!tokenable.Valid || !tokenable.TokenIsValid(user))
{
return false;
}
// duoClient compares the email from the received IdToken with user.Email to verify a bad actor hasn't used
// their authCode with a victims credentials
var res = await duoClient.ExchangeAuthorizationCodeFor2faResult(authCode, user.Email);
// If the result of the exchange doesn't throw an exception and it's not null, then it's valid
return res.AuthResult.Result == "allow";
}
private bool HasProperMetaData(TwoFactorProvider provider)
{
return provider?.MetaData != null && provider.MetaData.ContainsKey("ClientId") &&
provider.MetaData.ContainsKey("ClientSecret") && provider.MetaData.ContainsKey("Host");
}
/// <summary>
/// Checks if the metadata for SDK V2 is present.
/// Transitional method to support Duo during v4 database rename
/// </summary>
/// <param name="provider">The TwoFactorProvider object to check.</param>
/// <returns>True if the provider has the proper metadata; otherwise, false.</returns>
private bool HasProperMetaData_SDKV2(TwoFactorProvider provider)
{
if (provider?.MetaData != null &&
provider.MetaData.TryGetValue("IKey", out var iKey) &&
provider.MetaData.TryGetValue("SKey", out var sKey) &&
provider.MetaData.ContainsKey("Host"))
{
provider.MetaData.Add("ClientId", iKey);
provider.MetaData.Add("ClientSecret", sKey);
return true;
}
else
{
return false;
}
}
/// <summary>
/// Generates a Duo.Client object for use with Duo SDK v4. This combines the health check and the client generation
/// </summary>
/// <param name="provider">TwoFactorProvider Duo or OrganizationDuo</param>
/// <returns>Duo.Client object or null</returns>
private async Task<Duo.Client> BuildDuoClientAsync(TwoFactorProvider provider)
{
// Fetch Client name from header value since duo auth can be initiated from multiple clients and we want
// to redirect back to the initiating client
_currentContext.HttpContext.Request.Headers.TryGetValue("Bitwarden-Client-Name", out var bitwardenClientName);
var redirectUri = string.Format("{0}/duo-redirect-connector.html?client={1}",
_globalSettings.BaseServiceUri.Vault, bitwardenClientName.FirstOrDefault() ?? "web");
var client = new Duo.ClientBuilder(
(string)provider.MetaData["ClientId"],
(string)provider.MetaData["ClientSecret"],
(string)provider.MetaData["Host"],
redirectUri).Build();
if (!await client.DoHealthCheck(true))
{
_logger.LogError("Unable to connect to Duo. Health check failed.");
return null;
}
return client;
}
}

View File

@ -6,7 +6,7 @@ using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using OtpNet;
namespace Bit.Core.Auth.Identity;
namespace Bit.Core.Auth.Identity.TokenProviders;
public class AuthenticatorTokenProvider : IUserTwoFactorTokenProvider<User>
{

View File

@ -0,0 +1,102 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Tokens;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Duo = DuoUniversal;
namespace Bit.Core.Auth.Identity.TokenProviders;
public class DuoUniversalTokenProvider(
IServiceProvider serviceProvider,
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
IDuoUniversalTokenService duoUniversalTokenService) : IUserTwoFactorTokenProvider<User>
{
/// <summary>
/// We need the IServiceProvider to resolve the IUserService. There is a complex dependency dance
/// occurring between IUserService, which extends the UserManager<User>, and the usage of the
/// UserManager<User> within this class. Trying to resolve the IUserService using the DI pipeline
/// will not allow the server to start and it will hang and give no helpful indication as to the problem.
/// </summary>
private readonly IServiceProvider _serviceProvider = serviceProvider;
private readonly IDataProtectorTokenFactory<DuoUserStateTokenable> _tokenDataFactory = tokenDataFactory;
private readonly IDuoUniversalTokenService _duoUniversalTokenService = duoUniversalTokenService;
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
var provider = await GetDuoTwoFactorProvider(user, userService);
if (provider == null)
{
return false;
}
return await userService.TwoFactorProviderIsEnabledAsync(TwoFactorProviderType.Duo, user);
}
public async Task<string> GenerateAsync(string purpose, UserManager<User> manager, User user)
{
var duoClient = await GetDuoClientAsync(user);
if (duoClient == null)
{
return null;
}
return _duoUniversalTokenService.GenerateAuthUrl(duoClient, _tokenDataFactory, user);
}
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
{
var duoClient = await GetDuoClientAsync(user);
if (duoClient == null)
{
return false;
}
return await _duoUniversalTokenService.RequestDuoValidationAsync(duoClient, _tokenDataFactory, user, token);
}
/// <summary>
/// Get the Duo Two Factor Provider for the user if they have access to Duo
/// </summary>
/// <param name="user">Active User</param>
/// <returns>null or Duo TwoFactorProvider</returns>
private async Task<TwoFactorProvider> GetDuoTwoFactorProvider(User user, IUserService userService)
{
if (!await userService.CanAccessPremium(user))
{
return null;
}
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
if (!_duoUniversalTokenService.HasProperDuoMetadata(provider))
{
return null;
}
return provider;
}
/// <summary>
/// Uses the User to fetch a valid TwoFactorProvider and use it to create a Duo.Client
/// </summary>
/// <param name="user">active user</param>
/// <returns>null or Duo TwoFactorProvider</returns>
private async Task<Duo.Client> GetDuoClientAsync(User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
var provider = await GetDuoTwoFactorProvider(user, userService);
if (provider == null)
{
return null;
}
var duoClient = await _duoUniversalTokenService.BuildDuoTwoFactorClientAsync(provider);
if (duoClient == null)
{
return null;
}
return duoClient;
}
}

View File

@ -0,0 +1,177 @@
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Duo = DuoUniversal;
namespace Bit.Core.Auth.Identity.TokenProviders;
/// <summary>
/// OrganizationDuo and Duo TwoFactorProviderTypes both use the same flows so both of those Token Providers will
/// have this class injected to utilize these methods
/// </summary>
public interface IDuoUniversalTokenService
{
/// <summary>
/// Generates the Duo Auth URL for the user to be redirected to Duo for 2FA. This
/// Auth URL also lets the Duo Service know where to redirect the user back to after
/// the 2FA process is complete.
/// </summary>
/// <param name="duoClient">A not null valid Duo.Client</param>
/// <param name="tokenDataFactory">This service creates the state token for added security</param>
/// <param name="user">currently active user</param>
/// <returns>a URL in string format</returns>
string GenerateAuthUrl(
Duo.Client duoClient,
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
User user);
/// <summary>
/// Makes the request to Duo to validate the authCode and state token
/// </summary>
/// <param name="duoClient">A not null valid Duo.Client</param>
/// <param name="tokenDataFactory">Factory for decrypting the state</param>
/// <param name="user">self</param>
/// <param name="token">token received from the client</param>
/// <returns>boolean based on result from Duo</returns>
Task<bool> RequestDuoValidationAsync(
Duo.Client duoClient,
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
User user,
string token);
/// <summary>
/// Generates a Duo.Client object for use with Duo SDK v4. This method is to validate a Duo configuration
/// when adding or updating the configuration. This method makes a web request to Duo to verify the configuration.
/// Throws exception if configuration is invalid.
/// </summary>
/// <param name="clientSecret">Duo client Secret</param>
/// <param name="clientId">Duo client Id</param>
/// <param name="host">Duo host</param>
/// <returns>Boolean</returns>
Task<bool> ValidateDuoConfiguration(string clientSecret, string clientId, string host);
/// <summary>
/// Checks provider for the correct Duo metadata: ClientId, ClientSecret, and Host. Does no validation on the data.
/// it is assumed to be correct. The only way to have the data written to the Database is after verification
/// occurs.
/// </summary>
/// <param name="provider">Host being checked for proper data</param>
/// <returns>true if all three are present; false if one is missing or the host is incorrect</returns>
bool HasProperDuoMetadata(TwoFactorProvider provider);
/// <summary>
/// Generates a Duo.Client object for use with Duo SDK v4. This combines the health check and the client generation.
/// This method is made public so that it is easier to test. If the method was private then there would not be an
/// easy way to mock the response. Since this makes a web request it is difficult to mock.
/// </summary>
/// <param name="provider">TwoFactorProvider Duo or OrganizationDuo</param>
/// <returns>Duo.Client object or null</returns>
Task<Duo.Client> BuildDuoTwoFactorClientAsync(TwoFactorProvider provider);
}
public class DuoUniversalTokenService(
ICurrentContext currentContext,
GlobalSettings globalSettings) : IDuoUniversalTokenService
{
private readonly ICurrentContext _currentContext = currentContext;
private readonly GlobalSettings _globalSettings = globalSettings;
public string GenerateAuthUrl(
Duo.Client duoClient,
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
User user)
{
var state = tokenDataFactory.Protect(new DuoUserStateTokenable(user));
var authUrl = duoClient.GenerateAuthUri(user.Email, state);
return authUrl;
}
public async Task<bool> RequestDuoValidationAsync(
Duo.Client duoClient,
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
User user,
string token)
{
var parts = token.Split("|");
var authCode = parts[0];
var state = parts[1];
tokenDataFactory.TryUnprotect(state, out var tokenable);
if (!tokenable.Valid || !tokenable.TokenIsValid(user))
{
return false;
}
// duoClient compares the email from the received IdToken with user.Email to verify a bad actor hasn't used
// their authCode with a victims credentials
var res = await duoClient.ExchangeAuthorizationCodeFor2faResult(authCode, user.Email);
// If the result of the exchange doesn't throw an exception and it's not null, then it's valid
return res.AuthResult.Result == "allow";
}
public async Task<bool> ValidateDuoConfiguration(string clientSecret, string clientId, string host)
{
// Do some simple checks to ensure data integrity
if (!ValidDuoHost(host) ||
string.IsNullOrWhiteSpace(clientSecret) ||
string.IsNullOrWhiteSpace(clientId))
{
return false;
}
// The AuthURI is not important for this health check so we pass in a non-empty string
var client = new Duo.ClientBuilder(clientId, clientSecret, host, "non-empty").Build();
// This could throw an exception, the false flag will allow the exception to bubble up
return await client.DoHealthCheck(false);
}
public bool HasProperDuoMetadata(TwoFactorProvider provider)
{
return provider?.MetaData != null &&
provider.MetaData.ContainsKey("ClientId") &&
provider.MetaData.ContainsKey("ClientSecret") &&
provider.MetaData.ContainsKey("Host") &&
ValidDuoHost((string)provider.MetaData["Host"]);
}
/// <summary>
/// Checks the host string to make sure it meets Duo's Guidelines before attempting to create a Duo.Client.
/// </summary>
/// <param name="host">string representing the Duo Host</param>
/// <returns>true if the host is valid false otherwise</returns>
public static bool ValidDuoHost(string host)
{
if (Uri.TryCreate($"https://{host}", UriKind.Absolute, out var uri))
{
return (string.IsNullOrWhiteSpace(uri.PathAndQuery) || uri.PathAndQuery == "/") &&
uri.Host.StartsWith("api-") &&
(uri.Host.EndsWith(".duosecurity.com") || uri.Host.EndsWith(".duofederal.com"));
}
return false;
}
public async Task<Duo.Client> BuildDuoTwoFactorClientAsync(TwoFactorProvider provider)
{
// Fetch Client name from header value since duo auth can be initiated from multiple clients and we want
// to redirect back to the initiating client
_currentContext.HttpContext.Request.Headers.TryGetValue("Bitwarden-Client-Name", out var bitwardenClientName);
var redirectUri = string.Format("{0}/duo-redirect-connector.html?client={1}",
_globalSettings.BaseServiceUri.Vault, bitwardenClientName.FirstOrDefault() ?? "web");
var client = new Duo.ClientBuilder(
(string)provider.MetaData["ClientId"],
(string)provider.MetaData["ClientSecret"],
(string)provider.MetaData["Host"],
redirectUri).Build();
if (!await client.DoHealthCheck(false))
{
return null;
}
return client;
}
}

View File

@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.Identity;
namespace Bit.Core.Auth.Identity.TokenProviders;
public class EmailTokenProvider : IUserTwoFactorTokenProvider<User>
{

View File

@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.Identity;
namespace Bit.Core.Auth.Identity.TokenProviders;
public class EmailTwoFactorTokenProvider : EmailTokenProvider
{

View File

@ -1,7 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
namespace Bit.Core.Auth.Identity;
namespace Bit.Core.Auth.Identity.TokenProviders;
public interface IOrganizationTwoFactorTokenProvider
{

View File

@ -0,0 +1,81 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Entities;
using Bit.Core.Tokens;
using Duo = DuoUniversal;
namespace Bit.Core.Auth.Identity.TokenProviders;
public interface IOrganizationDuoUniversalTokenProvider : IOrganizationTwoFactorTokenProvider { }
public class OrganizationDuoUniversalTokenProvider(
IDataProtectorTokenFactory<DuoUserStateTokenable> tokenDataFactory,
IDuoUniversalTokenService duoUniversalTokenService) : IOrganizationDuoUniversalTokenProvider
{
private readonly IDataProtectorTokenFactory<DuoUserStateTokenable> _tokenDataFactory = tokenDataFactory;
private readonly IDuoUniversalTokenService _duoUniversalTokenService = duoUniversalTokenService;
public Task<bool> CanGenerateTwoFactorTokenAsync(Organization organization)
{
var provider = GetDuoTwoFactorProvider(organization);
if (provider != null && provider.Enabled)
{
return Task.FromResult(true);
}
return Task.FromResult(false);
}
public async Task<string> GenerateAsync(Organization organization, User user)
{
var duoClient = await GetDuoClientAsync(organization);
if (duoClient == null)
{
return null;
}
return _duoUniversalTokenService.GenerateAuthUrl(duoClient, _tokenDataFactory, user);
}
public async Task<bool> ValidateAsync(string token, Organization organization, User user)
{
var duoClient = await GetDuoClientAsync(organization);
if (duoClient == null)
{
return false;
}
return await _duoUniversalTokenService.RequestDuoValidationAsync(duoClient, _tokenDataFactory, user, token);
}
private TwoFactorProvider GetDuoTwoFactorProvider(Organization organization)
{
if (organization == null || !organization.Enabled || !organization.Use2fa)
{
return null;
}
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
if (!_duoUniversalTokenService.HasProperDuoMetadata(provider))
{
return null;
}
return provider;
}
private async Task<Duo.Client> GetDuoClientAsync(Organization organization)
{
var provider = GetDuoTwoFactorProvider(organization);
if (provider == null)
{
return null;
}
var duoClient = await _duoUniversalTokenService.BuildDuoTwoFactorClientAsync(provider);
if (duoClient == null)
{
return null;
}
return duoClient;
}
}

View File

@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Bit.Core.Auth.Identity;
namespace Bit.Core.Auth.Identity.TokenProviders;
public class TwoFactorRememberTokenProvider : DataProtectorTokenProvider<User>
{

View File

@ -10,7 +10,7 @@ using Fido2NetLib.Objects;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.Identity;
namespace Bit.Core.Auth.Identity.TokenProviders;
public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider<User>
{

View File

@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using YubicoDotNetClient;
namespace Bit.Core.Auth.Identity;
namespace Bit.Core.Auth.Identity.TokenProviders;
public class YubicoOtpTokenProvider : IUserTwoFactorTokenProvider<User>
{
@ -24,7 +24,7 @@ public class YubicoOtpTokenProvider : IUserTwoFactorTokenProvider<User>
public async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
if (!await userService.CanAccessPremium(user))
{
return false;
}
@ -46,7 +46,7 @@ public class YubicoOtpTokenProvider : IUserTwoFactorTokenProvider<User>
public async Task<bool> ValidateAsync(string purpose, string token, UserManager<User> manager, User user)
{
var userService = _serviceProvider.GetRequiredService<IUserService>();
if (!(await userService.CanAccessPremium(user)))
if (!await userService.CanAccessPremium(user))
{
return false;
}

View File

@ -1,277 +0,0 @@
/*
Original source modified from https://github.com/duosecurity/duo_api_csharp
=============================================================================
=============================================================================
Copyright (c) 2018 Duo Security
All rights reserved
*/
using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Web;
using Bit.Core.Models.Api.Response.Duo;
namespace Bit.Core.Auth.Utilities;
public class DuoApi
{
private const string UrlScheme = "https";
private const string UserAgent = "Bitwarden_DuoAPICSharp/1.0 (.NET Core)";
private readonly string _host;
private readonly string _ikey;
private readonly string _skey;
private readonly HttpClient _httpClient = new();
public DuoApi(string ikey, string skey, string host)
{
_ikey = ikey;
_skey = skey;
_host = host;
if (!ValidHost(host))
{
throw new DuoException("Invalid Duo host configured.", new ArgumentException(nameof(host)));
}
}
public static bool ValidHost(string host)
{
if (Uri.TryCreate($"https://{host}", UriKind.Absolute, out var uri))
{
return (string.IsNullOrWhiteSpace(uri.PathAndQuery) || uri.PathAndQuery == "/") &&
uri.Host.StartsWith("api-") &&
(uri.Host.EndsWith(".duosecurity.com") || uri.Host.EndsWith(".duofederal.com"));
}
return false;
}
public static string CanonicalizeParams(Dictionary<string, string> parameters)
{
var ret = new List<string>();
foreach (var pair in parameters)
{
var p = string.Format("{0}={1}", HttpUtility.UrlEncode(pair.Key), HttpUtility.UrlEncode(pair.Value));
// Signatures require upper-case hex digits.
p = Regex.Replace(p, "(%[0-9A-Fa-f][0-9A-Fa-f])", c => c.Value.ToUpperInvariant());
// Escape only the expected characters.
p = Regex.Replace(p, "([!'()*])", c => "%" + Convert.ToByte(c.Value[0]).ToString("X"));
p = p.Replace("%7E", "~");
// UrlEncode converts space (" ") to "+". The
// signature algorithm requires "%20" instead. Actual
// + has already been replaced with %2B.
p = p.Replace("+", "%20");
ret.Add(p);
}
ret.Sort(StringComparer.Ordinal);
return string.Join("&", ret.ToArray());
}
protected string CanonicalizeRequest(string method, string path, string canonParams, string date)
{
string[] lines = {
date,
method.ToUpperInvariant(),
_host.ToLower(),
path,
canonParams,
};
return string.Join("\n", lines);
}
public string Sign(string method, string path, string canonParams, string date)
{
var canon = CanonicalizeRequest(method, path, canonParams, date);
var sig = HmacSign(canon);
var auth = string.Concat(_ikey, ':', sig);
return string.Concat("Basic ", Encode64(auth));
}
/// <param name="timeout">The request timeout, in milliseconds.
/// Specify 0 to use the system-default timeout. Use caution if
/// you choose to specify a custom timeout - some API
/// calls (particularly in the Auth APIs) will not
/// return a response until an out-of-band authentication process
/// has completed. In some cases, this may take as much as a
/// small number of minutes.</param>
private async Task<(string result, HttpStatusCode statusCode)> ApiCall(string method, string path, Dictionary<string, string> parameters, int timeout)
{
if (parameters == null)
{
parameters = new Dictionary<string, string>();
}
var canonParams = CanonicalizeParams(parameters);
var query = string.Empty;
if (!method.Equals("POST") && !method.Equals("PUT"))
{
if (parameters.Count > 0)
{
query = "?" + canonParams;
}
}
var url = $"{UrlScheme}://{_host}{path}{query}";
var dateString = RFC822UtcNow();
var auth = Sign(method, path, canonParams, dateString);
var request = new HttpRequestMessage
{
Method = new HttpMethod(method),
RequestUri = new Uri(url),
};
request.Headers.Add("Authorization", auth);
request.Headers.Add("X-Duo-Date", dateString);
request.Headers.UserAgent.ParseAdd(UserAgent);
if (timeout > 0)
{
_httpClient.Timeout = TimeSpan.FromMilliseconds(timeout);
}
if (method.Equals("POST") || method.Equals("PUT"))
{
request.Content = new StringContent(canonParams, Encoding.UTF8, "application/x-www-form-urlencoded");
}
var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadAsStringAsync();
var statusCode = response.StatusCode;
return (result, statusCode);
}
public async Task<Response> JSONApiCall(string method, string path, Dictionary<string, string> parameters = null)
{
return await JSONApiCall(method, path, parameters, 0);
}
/// <param name="timeout">The request timeout, in milliseconds.
/// Specify 0 to use the system-default timeout. Use caution if
/// you choose to specify a custom timeout - some API
/// calls (particularly in the Auth APIs) will not
/// return a response until an out-of-band authentication process
/// has completed. In some cases, this may take as much as a
/// small number of minutes.</param>
private async Task<Response> JSONApiCall(string method, string path, Dictionary<string, string> parameters, int timeout)
{
var (res, statusCode) = await ApiCall(method, path, parameters, timeout);
try
{
var obj = JsonSerializer.Deserialize<DuoResponseModel>(res);
if (obj.Stat == "OK")
{
return obj.Response;
}
throw new ApiException(obj.Code ?? 0, (int)statusCode, obj.Message, obj.MessageDetail);
}
catch (ApiException)
{
throw;
}
catch (Exception e)
{
throw new BadResponseException((int)statusCode, e);
}
}
private int? ToNullableInt(string s)
{
int i;
if (int.TryParse(s, out i))
{
return i;
}
return null;
}
private string HmacSign(string data)
{
var keyBytes = Encoding.ASCII.GetBytes(_skey);
var dataBytes = Encoding.ASCII.GetBytes(data);
using (var hmac = new HMACSHA1(keyBytes))
{
var hash = hmac.ComputeHash(dataBytes);
var hex = BitConverter.ToString(hash);
return hex.Replace("-", string.Empty).ToLower();
}
}
private static string Encode64(string plaintext)
{
var plaintextBytes = Encoding.ASCII.GetBytes(plaintext);
return Convert.ToBase64String(plaintextBytes);
}
private static string RFC822UtcNow()
{
// Can't use the "zzzz" format because it adds a ":"
// between the offset's hours and minutes.
var dateString = DateTime.UtcNow.ToString("ddd, dd MMM yyyy HH:mm:ss", CultureInfo.InvariantCulture);
var offset = 0;
var zone = "+" + offset.ToString(CultureInfo.InvariantCulture).PadLeft(2, '0');
dateString += " " + zone.PadRight(5, '0');
return dateString;
}
}
public class DuoException : Exception
{
public int HttpStatus { get; private set; }
public DuoException(string message, Exception inner)
: base(message, inner)
{ }
public DuoException(int httpStatus, string message, Exception inner)
: base(message, inner)
{
HttpStatus = httpStatus;
}
}
public class ApiException : DuoException
{
public int Code { get; private set; }
public string ApiMessage { get; private set; }
public string ApiMessageDetail { get; private set; }
public ApiException(int code, int httpStatus, string apiMessage, string apiMessageDetail)
: base(httpStatus, FormatMessage(code, apiMessage, apiMessageDetail), null)
{
Code = code;
ApiMessage = apiMessage;
ApiMessageDetail = apiMessageDetail;
}
private static string FormatMessage(int code, string apiMessage, string apiMessageDetail)
{
return string.Format("Duo API Error {0}: '{1}' ('{2}')", code, apiMessage, apiMessageDetail);
}
}
public class BadResponseException : DuoException
{
public BadResponseException(int httpStatus, Exception inner)
: base(httpStatus, FormatMessage(httpStatus, inner), inner)
{ }
private static string FormatMessage(int httpStatus, Exception inner)
{
var innerMessage = "(null)";
if (inner != null)
{
innerMessage = string.Format("'{0}'", inner.Message);
}
return string.Format("Got error {0} with HTTP Status {1}", innerMessage, httpStatus);
}
}

View File

@ -1,240 +0,0 @@
/*
Original source modified from https://github.com/duosecurity/duo_dotnet
=============================================================================
=============================================================================
ref: https://github.com/duosecurity/duo_dotnet/blob/master/LICENSE
Copyright (c) 2011, Duo Security, Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. The name of the author may not be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
using System.Security.Cryptography;
using System.Text;
namespace Bit.Core.Auth.Utilities.Duo;
public static class DuoWeb
{
private const string DuoProfix = "TX";
private const string AppPrefix = "APP";
private const string AuthPrefix = "AUTH";
private const int DuoExpire = 300;
private const int AppExpire = 3600;
private const int IKeyLength = 20;
private const int SKeyLength = 40;
private const int AKeyLength = 40;
public static string ErrorUser = "ERR|The username passed to sign_request() is invalid.";
public static string ErrorIKey = "ERR|The Duo integration key passed to sign_request() is invalid.";
public static string ErrorSKey = "ERR|The Duo secret key passed to sign_request() is invalid.";
public static string ErrorAKey = "ERR|The application secret key passed to sign_request() must be at least " +
"40 characters.";
public static string ErrorUnknown = "ERR|An unknown error has occurred.";
// throw on invalid bytes
private static Encoding _encoding = new UTF8Encoding(false, true);
private static DateTime _epoc = new DateTime(1970, 1, 1);
/// <summary>
/// Generate a signed request for Duo authentication.
/// The returned value should be passed into the Duo.init() call
/// in the rendered web page used for Duo authentication.
/// </summary>
/// <param name="ikey">Duo integration key</param>
/// <param name="skey">Duo secret key</param>
/// <param name="akey">Application secret key</param>
/// <param name="username">Primary-authenticated username</param>
/// <param name="currentTime">(optional) The current UTC time</param>
/// <returns>signed request</returns>
public static string SignRequest(string ikey, string skey, string akey, string username,
DateTime? currentTime = null)
{
string duoSig;
string appSig;
var currentTimeValue = currentTime ?? DateTime.UtcNow;
if (username == string.Empty)
{
return ErrorUser;
}
if (username.Contains("|"))
{
return ErrorUser;
}
if (ikey.Length != IKeyLength)
{
return ErrorIKey;
}
if (skey.Length != SKeyLength)
{
return ErrorSKey;
}
if (akey.Length < AKeyLength)
{
return ErrorAKey;
}
try
{
duoSig = SignVals(skey, username, ikey, DuoProfix, DuoExpire, currentTimeValue);
appSig = SignVals(akey, username, ikey, AppPrefix, AppExpire, currentTimeValue);
}
catch
{
return ErrorUnknown;
}
return $"{duoSig}:{appSig}";
}
/// <summary>
/// Validate the signed response returned from Duo.
/// Returns the username of the authenticated user, or null.
/// </summary>
/// <param name="ikey">Duo integration key</param>
/// <param name="skey">Duo secret key</param>
/// <param name="akey">Application secret key</param>
/// <param name="sigResponse">The signed response POST'ed to the server</param>
/// <param name="currentTime">(optional) The current UTC time</param>
/// <returns>authenticated username, or null</returns>
public static string VerifyResponse(string ikey, string skey, string akey, string sigResponse,
DateTime? currentTime = null)
{
string authUser = null;
string appUser = null;
var currentTimeValue = currentTime ?? DateTime.UtcNow;
try
{
var sigs = sigResponse.Split(':');
var authSig = sigs[0];
var appSig = sigs[1];
authUser = ParseVals(skey, authSig, AuthPrefix, ikey, currentTimeValue);
appUser = ParseVals(akey, appSig, AppPrefix, ikey, currentTimeValue);
}
catch
{
return null;
}
if (authUser != appUser)
{
return null;
}
return authUser;
}
private static string SignVals(string key, string username, string ikey, string prefix, long expire,
DateTime currentTime)
{
var ts = (long)(currentTime - _epoc).TotalSeconds;
expire = ts + expire;
var val = $"{username}|{ikey}|{expire.ToString()}";
var cookie = $"{prefix}|{Encode64(val)}";
var sig = Sign(key, cookie);
return $"{cookie}|{sig}";
}
private static string ParseVals(string key, string val, string prefix, string ikey, DateTime currentTime)
{
var ts = (long)(currentTime - _epoc).TotalSeconds;
var parts = val.Split('|');
if (parts.Length != 3)
{
return null;
}
var uPrefix = parts[0];
var uB64 = parts[1];
var uSig = parts[2];
var sig = Sign(key, $"{uPrefix}|{uB64}");
if (Sign(key, sig) != Sign(key, uSig))
{
return null;
}
if (uPrefix != prefix)
{
return null;
}
var cookie = Decode64(uB64);
var cookieParts = cookie.Split('|');
if (cookieParts.Length != 3)
{
return null;
}
var username = cookieParts[0];
var uIKey = cookieParts[1];
var expire = cookieParts[2];
if (uIKey != ikey)
{
return null;
}
var expireTs = Convert.ToInt32(expire);
if (ts >= expireTs)
{
return null;
}
return username;
}
private static string Sign(string skey, string data)
{
var keyBytes = Encoding.ASCII.GetBytes(skey);
var dataBytes = Encoding.ASCII.GetBytes(data);
using (var hmac = new HMACSHA1(keyBytes))
{
var hash = hmac.ComputeHash(dataBytes);
var hex = BitConverter.ToString(hash);
return hex.Replace("-", "").ToLower();
}
}
private static string Encode64(string plaintext)
{
var plaintextBytes = _encoding.GetBytes(plaintext);
return Convert.ToBase64String(plaintextBytes);
}
private static string Decode64(string encoded)
{
var plaintextBytes = Convert.FromBase64String(encoded);
return _encoding.GetString(plaintextBytes);
}
}

View File

@ -11,22 +11,29 @@ namespace Bit.Core.Billing.Extensions;
public static class BillingExtensions
{
public static bool IsBillable(this Provider provider) =>
provider.SupportsConsolidatedBilling() && provider.Status == ProviderStatusType.Billable;
provider is
{
Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise,
Status: ProviderStatusType.Billable
};
public static bool SupportsConsolidatedBilling(this Provider provider)
=> provider.Type is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
public static bool IsValidClient(this Organization organization)
=> organization is
{
Seats: not null,
Status: OrganizationStatusType.Managed,
PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly
PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually
};
public static bool IsStripeEnabled(this ISubscriber subscriber)
=> !string.IsNullOrEmpty(subscriber.GatewayCustomerId) &&
!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId);
=> subscriber is
{
GatewayCustomerId: not null and not "",
GatewaySubscriptionId: not null and not ""
};
public static bool IsUnverifiedBankAccount(this SetupIntent setupIntent) =>
setupIntent is

View File

@ -4,4 +4,5 @@ public record OrganizationMetadata(
bool IsEligibleForSelfHost,
bool IsManaged,
bool IsOnSecretsManagerStandalone,
bool IsSubscriptionUnpaid);
bool IsSubscriptionUnpaid,
bool HasSubscription);

View File

@ -1,6 +1,5 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Services.Contracts;
@ -12,18 +11,10 @@ namespace Bit.Core.Billing.Services;
public interface IProviderBillingService
{
/// <summary>
/// Assigns a specified number of <paramref name="seats"/> to a client <paramref name="organization"/> on behalf of
/// its <paramref name="provider"/>. Seat adjustments for the client organization may autoscale the provider's Stripe
/// <see cref="Stripe.Subscription"/> depending on the provider's seat minimum for the client <paramref name="organization"/>'s
/// <see cref="PlanType"/>.
/// Changes the assigned provider plan for the provider.
/// </summary>
/// <param name="provider">The <see cref="Provider"/> that manages the client <paramref name="organization"/>.</param>
/// <param name="organization">The client <see cref="Organization"/> whose <paramref name="seats"/> you want to update.</param>
/// <param name="seats">The number of seats to assign to the client organization.</param>
Task AssignSeatsToClientOrganization(
Provider provider,
Organization organization,
int seats);
/// <param name="command">The command to change the provider plan.</param>
Task ChangePlan(ChangeProviderPlanCommand command);
/// <summary>
/// Create a Stripe <see cref="Stripe.Customer"/> for the provided client <paramref name="organization"/> utilizing
@ -44,18 +35,6 @@ public interface IProviderBillingService
Task<byte[]> GenerateClientInvoiceReport(
string invoiceId);
/// <summary>
/// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>.
/// </summary>
/// <param name="providerId">The ID of the MSP to retrieve the assigned seat total for.</param>
/// <param name="planType">The type of plan to retrieve the assigned seat total for.</param>
/// <returns>An <see cref="int"/> representing the number of seats the provider has assigned to its client organizations with the specified <paramref name="planType"/>.</returns>
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> is <see langword="null"/>.</exception>
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> has <see cref="Provider.Type"/> <see cref="ProviderType.Reseller"/>.</exception>
Task<int> GetAssignedSeatTotalForPlanOrThrow(
Guid providerId,
PlanType planType);
/// <summary>
/// Scales the <paramref name="provider"/>'s seats for the specified <paramref name="planType"/> using the provided <paramref name="seatAdjustment"/>.
/// This operation may autoscale the provider's Stripe <see cref="Stripe.Subscription"/> depending on the <paramref name="provider"/>'s seat minimum for the
@ -69,6 +48,22 @@ public interface IProviderBillingService
PlanType planType,
int seatAdjustment);
/// <summary>
/// Determines whether the provided <paramref name="seatAdjustment"/> will result in a purchase for the <paramref name="provider"/>'s <see cref="planType"/>.
/// Seat adjustments that result in purchases include:
/// <list type="bullet">
/// <item>The <paramref name="provider"/> going from below the seat minimum to above the seat minimum for the provided <paramref name="planType"/></item>
/// <item>The <paramref name="provider"/> going from above the seat minimum to further above the seat minimum for the provided <paramref name="planType"/></item>
/// </list>
/// </summary>
/// <param name="provider">The provider to check seat adjustments for.</param>
/// <param name="planType">The plan type to check seat adjustments for.</param>
/// <param name="seatAdjustment">The change in seats for the <paramref name="provider"/>'s <paramref name="planType"/>.</param>
Task<bool> SeatAdjustmentResultsInPurchase(
Provider provider,
PlanType planType,
int seatAdjustment);
/// <summary>
/// For use during the provider setup process, this method creates a Stripe <see cref="Stripe.Customer"/> for the specified <paramref name="provider"/> utilizing the provided <paramref name="taxInfo"/>.
/// </summary>
@ -90,12 +85,5 @@ public interface IProviderBillingService
Task<Subscription> SetupSubscription(
Provider provider);
/// <summary>
/// Changes the assigned provider plan for the provider.
/// </summary>
/// <param name="command">The command to change the provider plan.</param>
/// <returns></returns>
Task ChangePlan(ChangeProviderPlanCommand command);
Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command);
}

View File

@ -62,18 +62,25 @@ public class OrganizationBillingService(
return null;
}
var isEligibleForSelfHost = IsEligibleForSelfHost(organization);
var isManaged = organization.Status == OrganizationStatusType.Managed;
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
return new OrganizationMetadata(isEligibleForSelfHost, isManaged, false,
false, false);
}
var customer = await subscriberService.GetCustomer(organization,
new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] });
var subscription = await subscriberService.GetSubscription(organization);
var isEligibleForSelfHost = IsEligibleForSelfHost(organization);
var isManaged = organization.Status == OrganizationStatusType.Managed;
var isOnSecretsManagerStandalone = IsOnSecretsManagerStandalone(organization, customer, subscription);
var isSubscriptionUnpaid = IsSubscriptionUnpaid(subscription);
var hasSubscription = true;
return new OrganizationMetadata(isEligibleForSelfHost, isManaged, isOnSecretsManagerStandalone,
isSubscriptionUnpaid);
isSubscriptionUnpaid, hasSubscription);
}
public async Task UpdatePaymentMethod(

View File

@ -107,11 +107,9 @@ public static class FeatureFlagKeys
public const string ItemShare = "item-share";
public const string DuoRedirect = "duo-redirect";
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
public const string EnableConsolidatedBilling = "enable-consolidated-billing";
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
public const string EmailVerification = "email-verification";
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
public const string AnhFcmv1Migration = "anh-fcmv1-migration";
public const string ExtensionRefresh = "extension-refresh";
public const string RestrictProviderAccess = "restrict-provider-access";
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
@ -126,7 +124,6 @@ public static class FeatureFlagKeys
public const string SSHKeyItemVaultItem = "ssh-key-vault-item";
public const string SSHAgent = "ssh-agent";
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
public const string EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub";
public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh";
public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor";
@ -143,6 +140,7 @@ public static class FeatureFlagKeys
public const string StorageReseedRefactor = "storage-reseed-refactor";
public const string TrialPayment = "PM-8163-trial-payment";
public const string RemoveServerVersionHeader = "remove-server-version-header";
public const string SecureOrgGroupDetails = "pm-3479-secure-org-group-details";
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";
@ -155,6 +153,7 @@ public static class FeatureFlagKeys
public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss";
public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss";
public const string SecurityTasks = "security-tasks";
public const string PM14401_ScaleMSPOnClientOrganizationUpdate = "PM-14401-scale-msp-on-client-organization-update";
public static List<string> GetAllKeys()
{

View File

@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Context;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Extensions;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Identity;
@ -363,9 +364,9 @@ public class CurrentContext : ICurrentContext
public async Task<bool> ViewSubscription(Guid orgId)
{
var orgManagedByMspProvider = (await GetOrganizationProviderDetails()).Any(po => po.OrganizationId == orgId && po.ProviderType == ProviderType.Msp);
var isManagedByBillableProvider = (await GetOrganizationProviderDetails()).Any(po => po.OrganizationId == orgId && po.ProviderType.SupportsConsolidatedBilling());
return orgManagedByMspProvider
return isManagedByBillableProvider
? await ProviderUserForOrgAsync(orgId)
: await OrganizationOwner(orgId);
}

View File

@ -4,7 +4,6 @@ using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Microsoft.Azure.NotificationHubs;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Bit.Core.NotificationHub;
@ -60,21 +59,10 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService
switch (type)
{
case DeviceType.Android:
var featureService = _serviceProvider.GetRequiredService<IFeatureService>();
if (featureService.IsEnabled(FeatureFlagKeys.AnhFcmv1Migration))
{
payloadTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\",\"payload\":\"$(payload)\"}}}";
messageTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\"}," +
"\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}";
installation.Platform = NotificationPlatform.FcmV1;
}
else
{
payloadTemplate = "{\"data\":{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}}}";
messageTemplate = "{\"data\":{\"data\":{\"type\":\"#(type)\"}," +
"\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}";
installation.Platform = NotificationPlatform.Fcm;
}
break;
case DeviceType.iOS:
payloadTemplate = "{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}," +

View File

@ -0,0 +1,20 @@
using Bit.Core.Entities;
using Bit.Core.Utilities;
namespace Bit.Core.Vault.Entities;
public class SecurityTask : ITableObject<Guid>
{
public Guid Id { get; set; }
public Guid OrganizationId { get; set; }
public Guid? CipherId { get; set; }
public Enums.SecurityTaskType Type { get; set; }
public Enums.SecurityTaskStatus Status { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
public void SetNewId()
{
Id = CoreHelpers.GenerateComb();
}
}

View File

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Core.Vault.Enums;
public enum SecurityTaskStatus : byte
{
/// <summary>
/// Default status for newly created tasks that have not been completed.
/// </summary>
[Display(Name = "Pending")]
Pending = 0,
/// <summary>
/// Status when a task is considered complete and has no remaining actions
/// </summary>
[Display(Name = "Completed")]
Completed = 1,
}

View File

@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Core.Vault.Enums;
public enum SecurityTaskType : byte
{
/// <summary>
/// Task to update a cipher's password that was found to be at-risk by an administrator
/// </summary>
[Display(Name = "Update at-risk credential")]
UpdateAtRiskCredential = 0
}

View File

@ -0,0 +1,9 @@
using Bit.Core.Repositories;
using Bit.Core.Vault.Entities;
namespace Bit.Core.Vault.Repositories;
public interface ISecurityTaskRepository : IRepository<SecurityTask, Guid>
{
}

View File

@ -1,9 +1,7 @@

using System.Text.Json;
using Bit.Core;
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Context;
@ -52,8 +50,7 @@ public interface ITwoFactorAuthenticationValidator
public class TwoFactorAuthenticationValidator(
IUserService userService,
UserManager<User> userManager,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
ITemporaryDuoWebV4SDKService duoWebV4SDKService,
IOrganizationDuoUniversalTokenProvider organizationDuoWebTokenProvider,
IFeatureService featureService,
IApplicationCacheService applicationCacheService,
IOrganizationUserRepository organizationUserRepository,
@ -63,8 +60,7 @@ public class TwoFactorAuthenticationValidator(
{
private readonly IUserService _userService = userService;
private readonly UserManager<User> _userManager = userManager;
private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider = organizationDuoWebTokenProvider;
private readonly ITemporaryDuoWebV4SDKService _duoWebV4SDKService = duoWebV4SDKService;
private readonly IOrganizationDuoUniversalTokenProvider _organizationDuoUniversalTokenProvider = organizationDuoWebTokenProvider;
private readonly IFeatureService _featureService = featureService;
private readonly IApplicationCacheService _applicationCacheService = applicationCacheService;
private readonly IOrganizationUserRepository _organizationUserRepository = organizationUserRepository;
@ -153,17 +149,7 @@ public class TwoFactorAuthenticationValidator(
{
if (organization.TwoFactorProviderIsEnabled(type))
{
// DUO SDK v4 Update: try to validate the token - PM-5156 addresses tech debt
if (_featureService.IsEnabled(FeatureFlagKeys.DuoRedirect))
{
if (!token.Contains(':'))
{
// We have to send the provider to the DuoWebV4SDKService to create the DuoClient
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
return await _duoWebV4SDKService.ValidateAsync(token, provider, user);
}
}
return await _organizationDuoWebTokenProvider.ValidateAsync(token, organization, user);
return await _organizationDuoUniversalTokenProvider.ValidateAsync(token, organization, user);
}
return false;
}
@ -181,19 +167,6 @@ public class TwoFactorAuthenticationValidator(
{
return false;
}
// DUO SDK v4 Update: try to validate the token - PM-5156 addresses tech debt
if (_featureService.IsEnabled(FeatureFlagKeys.DuoRedirect))
{
if (type == TwoFactorProviderType.Duo)
{
if (!token.Contains(':'))
{
// We have to send the provider to the DuoWebV4SDKService to create the DuoClient
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
return await _duoWebV4SDKService.ValidateAsync(token, provider, user);
}
}
}
return await _userManager.VerifyTwoFactorTokenAsync(user,
CoreHelpers.CustomProviderName(type), token);
default:
@ -248,10 +221,11 @@ public class TwoFactorAuthenticationValidator(
in the future the `AuthUrl` will be the generated "token" - PM-8107
*/
if (type == TwoFactorProviderType.OrganizationDuo &&
await _organizationDuoWebTokenProvider.CanGenerateTwoFactorTokenAsync(organization))
await _organizationDuoUniversalTokenProvider.CanGenerateTwoFactorTokenAsync(organization))
{
twoFactorParams.Add("Host", provider.MetaData["Host"]);
twoFactorParams.Add("AuthUrl", await _duoWebV4SDKService.GenerateAsync(provider, user));
twoFactorParams.Add("AuthUrl",
await _organizationDuoUniversalTokenProvider.GenerateAsync(organization, user));
return twoFactorParams;
}
@ -261,13 +235,9 @@ public class TwoFactorAuthenticationValidator(
CoreHelpers.CustomProviderName(type));
switch (type)
{
/*
Note: Duo is in the midst of being updated to use the UserManager built-in TwoFactor class
in the future the `AuthUrl` will be the generated "token" - PM-8107
*/
case TwoFactorProviderType.Duo:
twoFactorParams.Add("Host", provider.MetaData["Host"]);
twoFactorParams.Add("AuthUrl", await _duoWebV4SDKService.GenerateAsync(provider, user));
twoFactorParams.Add("AuthUrl", token);
break;
case TwoFactorProviderType.WebAuthn:
if (token != null)

View File

@ -59,6 +59,7 @@ public static class DapperServiceCollectionExtensions
services
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
if (selfHosted)
{

View File

@ -0,0 +1,18 @@
using Bit.Core.Settings;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Repositories;
using Bit.Infrastructure.Dapper.Repositories;
namespace Bit.Infrastructure.Dapper.Vault.Repositories;
public class SecurityTaskRepository : Repository<SecurityTask, Guid>, ISecurityTaskRepository
{
public SecurityTaskRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ }
public SecurityTaskRepository(string connectionString, string readOnlyConnectionString)
: base(connectionString, readOnlyConnectionString)
{ }
}

View File

@ -96,6 +96,7 @@ public static class EntityFrameworkServiceCollectionExtensions
services
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
if (selfHosted)
{

View File

@ -77,6 +77,7 @@ public class DatabaseContext : DbContext
public DbSet<NotificationStatus> NotificationStatuses { get; set; }
public DbSet<ClientOrganizationMigrationRecord> ClientOrganizationMigrationRecords { get; set; }
public DbSet<PasswordHealthReportApplication> PasswordHealthReportApplications { get; set; }
public DbSet<SecurityTask> SecurityTasks { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{

View File

@ -0,0 +1,30 @@
using Bit.Infrastructure.EntityFramework.Vault.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Bit.Infrastructure.EntityFramework.Vault.Configurations;
public class SecurityTaskEntityTypeConfiguration : IEntityTypeConfiguration<SecurityTask>
{
public void Configure(EntityTypeBuilder<SecurityTask> builder)
{
builder
.Property(s => s.Id)
.ValueGeneratedNever();
builder
.HasKey(s => s.Id)
.IsClustered();
builder
.HasIndex(s => s.OrganizationId)
.IsClustered(false);
builder
.HasIndex(s => s.CipherId)
.IsClustered(false);
builder
.ToTable(nameof(SecurityTask));
}
}

View File

@ -0,0 +1,18 @@
using AutoMapper;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
namespace Bit.Infrastructure.EntityFramework.Vault.Models;
public class SecurityTask : Core.Vault.Entities.SecurityTask
{
public virtual Organization Organization { get; set; }
public virtual Cipher Cipher { get; set; }
}
public class SecurityTaskMapperProfile : Profile
{
public SecurityTaskMapperProfile()
{
CreateMap<Core.Vault.Entities.SecurityTask, SecurityTask>().ReverseMap();
}
}

View File

@ -0,0 +1,14 @@
using AutoMapper;
using Bit.Core.Vault.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.Vault.Models;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Infrastructure.EntityFramework.Vault.Repositories;
public class SecurityTaskRepository : Repository<Core.Vault.Entities.SecurityTask, SecurityTask, Guid>, ISecurityTaskRepository
{
public SecurityTaskRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
: base(serviceScopeFactory, mapper, (context) => context.SecurityTasks)
{ }
}

View File

@ -10,6 +10,7 @@ using Bit.Core.AdminConsole.Services.Implementations;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Auth.IdentityServer;
using Bit.Core.Auth.LoginFeatures;
using Bit.Core.Auth.Models.Business.Tokenables;
@ -113,6 +114,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IDeviceService, DeviceService>();
services.AddScoped<ISsoConfigService, SsoConfigService>();
services.AddScoped<IAuthRequestService, AuthRequestService>();
services.AddScoped<IDuoUniversalTokenService, DuoUniversalTokenService>();
services.AddScoped<ISendService, SendService>();
services.AddLoginServices();
services.AddScoped<IOrganizationDomainService, OrganizationDomainService>();
@ -388,8 +390,7 @@ public static class ServiceCollectionExtensions
public static IdentityBuilder AddCustomIdentityServices(
this IServiceCollection services, GlobalSettings globalSettings)
{
services.AddScoped<IOrganizationDuoWebTokenProvider, OrganizationDuoWebTokenProvider>();
services.AddScoped<ITemporaryDuoWebV4SDKService, TemporaryDuoWebV4SDKService>();
services.AddScoped<IOrganizationDuoUniversalTokenProvider, OrganizationDuoUniversalTokenProvider>();
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 100000);
services.Configure<TwoFactorRememberTokenProviderOptions>(options =>
{
@ -430,7 +431,7 @@ public static class ServiceCollectionExtensions
CoreHelpers.CustomProviderName(TwoFactorProviderType.Email))
.AddTokenProvider<YubicoOtpTokenProvider>(
CoreHelpers.CustomProviderName(TwoFactorProviderType.YubiKey))
.AddTokenProvider<DuoWebTokenProvider>(
.AddTokenProvider<DuoUniversalTokenProvider>(
CoreHelpers.CustomProviderName(TwoFactorProviderType.Duo))
.AddTokenProvider<TwoFactorRememberTokenProvider>(
CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember))

Some files were not shown because too many files have changed in this diff Show More