mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +01:00
Merge branch 'main' into main
This commit is contained in:
commit
c491d4478b
1
.gitignore
vendored
1
.gitignore
vendored
@ -215,6 +215,7 @@ bitwarden_license/src/Sso/wwwroot/css
|
||||
**/CoverageOutput/
|
||||
.idea/*
|
||||
**/**.swp
|
||||
.mono
|
||||
|
||||
src/Admin/Admin.zip
|
||||
src/Api/Api.zip
|
||||
|
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2024.6.2</Version>
|
||||
<Version>2024.7.0</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
@ -436,11 +436,15 @@ public class ProviderService : IProviderService
|
||||
return;
|
||||
}
|
||||
|
||||
var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId, GetStripeSeatPlanId(organization.PlanType));
|
||||
var extractedPlanType = PlanTypeMappings(organization);
|
||||
if (subscriptionItem != null)
|
||||
if (!string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
|
||||
{
|
||||
await UpdateSubscriptionAsync(subscriptionItem, GetStripeSeatPlanId(extractedPlanType), organization);
|
||||
var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId,
|
||||
GetStripeSeatPlanId(organization.PlanType));
|
||||
var extractedPlanType = PlanTypeMappings(organization);
|
||||
if (subscriptionItem != null)
|
||||
{
|
||||
await UpdateSubscriptionAsync(subscriptionItem, GetStripeSeatPlanId(extractedPlanType), organization);
|
||||
}
|
||||
}
|
||||
|
||||
await _organizationRepository.UpsertAsync(organization);
|
||||
|
@ -1,21 +1,25 @@
|
||||
using System.Globalization;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace Bit.Commercial.Core.Billing.Models;
|
||||
|
||||
public class ProviderClientInvoiceReportRow
|
||||
{
|
||||
public string Client { get; set; }
|
||||
public string Id { get; set; }
|
||||
public int Assigned { get; set; }
|
||||
public int Used { get; set; }
|
||||
public int Remaining { get; set; }
|
||||
public string Plan { get; set; }
|
||||
[Name("Estimated total")]
|
||||
public string Total { get; set; }
|
||||
|
||||
public static ProviderClientInvoiceReportRow From(ProviderInvoiceItem providerInvoiceItem)
|
||||
=> new()
|
||||
{
|
||||
Client = providerInvoiceItem.ClientName,
|
||||
Id = providerInvoiceItem.ClientId?.ToString(),
|
||||
Assigned = providerInvoiceItem.AssignedSeats,
|
||||
Used = providerInvoiceItem.UsedSeats,
|
||||
Remaining = providerInvoiceItem.AssignedSeats - providerInvoiceItem.UsedSeats,
|
||||
|
@ -1,6 +1,5 @@
|
||||
using System.Globalization;
|
||||
using Bit.Commercial.Core.Billing.Models;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
@ -29,7 +28,6 @@ namespace Bit.Commercial.Core.Billing;
|
||||
|
||||
public class ProviderBillingService(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<ProviderBillingService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -81,7 +79,7 @@ public class ProviderBillingService(
|
||||
if (string.IsNullOrEmpty(taxInfo.BillingAddressCountry) ||
|
||||
string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
|
||||
{
|
||||
logger.LogError("Cannot create Stripe customer for provider ({ID}) - Both the provider's country and postal code are required", provider.Id);
|
||||
logger.LogError("Cannot create customer for provider ({ProviderID}) without both a country and postal code", provider.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
@ -99,7 +97,6 @@ public class ProviderBillingService(
|
||||
City = taxInfo.BillingAddressCity,
|
||||
State = taxInfo.BillingAddressState
|
||||
},
|
||||
Coupon = "msp-discount-35",
|
||||
Description = provider.DisplayBusinessName(),
|
||||
Email = provider.BillingEmail,
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
@ -272,13 +269,6 @@ public class ProviderBillingService(
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
|
||||
if (provider.Type == ProviderType.Reseller)
|
||||
{
|
||||
logger.LogError("Consolidated billing subscription cannot be retrieved for reseller-type provider ({ID})", provider.Id);
|
||||
|
||||
throw ContactSupport("Consolidated billing does not support reseller-type providers");
|
||||
}
|
||||
|
||||
var subscription = await subscriberService.GetSubscription(provider, new SubscriptionGetOptions
|
||||
{
|
||||
Expand = ["customer", "test_clock"]
|
||||
@ -289,18 +279,6 @@ public class ProviderBillingService(
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime? subscriptionSuspensionDate = null;
|
||||
DateTime? subscriptionUnpaidPeriodEndDate = null;
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.AC1795_UpdatedSubscriptionStatusSection))
|
||||
{
|
||||
var (suspensionDate, unpaidPeriodEndDate) = await paymentService.GetSuspensionDateAsync(subscription);
|
||||
if (suspensionDate.HasValue && unpaidPeriodEndDate.HasValue)
|
||||
{
|
||||
subscriptionSuspensionDate = suspensionDate;
|
||||
subscriptionUnpaidPeriodEndDate = unpaidPeriodEndDate;
|
||||
}
|
||||
}
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
var configuredProviderPlans = providerPlans
|
||||
@ -308,11 +286,15 @@ public class ProviderBillingService(
|
||||
.Select(ConfiguredProviderPlanDTO.From)
|
||||
.ToList();
|
||||
|
||||
var taxInformation = await subscriberService.GetTaxInformation(provider);
|
||||
|
||||
var suspension = await GetSuspensionAsync(stripeAdapter, subscription);
|
||||
|
||||
return new ConsolidatedBillingSubscriptionDTO(
|
||||
configuredProviderPlans,
|
||||
subscription,
|
||||
subscriptionSuspensionDate,
|
||||
subscriptionUnpaidPeriodEndDate);
|
||||
taxInformation,
|
||||
suspension);
|
||||
}
|
||||
|
||||
public async Task ScaleSeats(
|
||||
@ -416,20 +398,13 @@ public class ProviderBillingService(
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
|
||||
if (!string.IsNullOrEmpty(provider.GatewaySubscriptionId))
|
||||
{
|
||||
logger.LogWarning("Cannot start Provider subscription - Provider ({ID}) already has a {FieldName}", provider.Id, nameof(provider.GatewaySubscriptionId));
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
var customer = await subscriberService.GetCustomerOrThrow(provider);
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
if (providerPlans == null || providerPlans.Count == 0)
|
||||
{
|
||||
logger.LogError("Cannot start Provider subscription - Provider ({ID}) has no configured plans", provider.Id);
|
||||
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", provider.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
@ -439,9 +414,9 @@ public class ProviderBillingService(
|
||||
var teamsProviderPlan =
|
||||
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
|
||||
|
||||
if (teamsProviderPlan == null)
|
||||
if (teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
|
||||
{
|
||||
logger.LogError("Cannot start Provider subscription - Provider ({ID}) has no configured Teams Monthly plan", provider.Id);
|
||||
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Teams plan", provider.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
@ -457,9 +432,9 @@ public class ProviderBillingService(
|
||||
var enterpriseProviderPlan =
|
||||
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
|
||||
|
||||
if (enterpriseProviderPlan == null)
|
||||
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured())
|
||||
{
|
||||
logger.LogError("Cannot start Provider subscription - Provider ({ID}) has no configured Enterprise Monthly plan", provider.Id);
|
||||
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Enterprise plan", provider.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
@ -498,7 +473,7 @@ public class ProviderBillingService(
|
||||
{
|
||||
await providerRepository.ReplaceAsync(provider);
|
||||
|
||||
logger.LogError("Started incomplete Provider ({ProviderID}) subscription ({SubscriptionID})", provider.Id, subscription.Id);
|
||||
logger.LogError("Started incomplete provider ({ProviderID}) subscription ({SubscriptionID})", provider.Id, subscription.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.Registration;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
@ -51,6 +52,7 @@ public class AccountController : Controller
|
||||
private readonly Core.Services.IEventService _eventService;
|
||||
private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector;
|
||||
private readonly IOrganizationDomainRepository _organizationDomainRepository;
|
||||
private readonly IRegisterUserCommand _registerUserCommand;
|
||||
|
||||
public AccountController(
|
||||
IAuthenticationSchemeProvider schemeProvider,
|
||||
@ -70,7 +72,8 @@ public class AccountController : Controller
|
||||
IGlobalSettings globalSettings,
|
||||
Core.Services.IEventService eventService,
|
||||
IDataProtectorTokenFactory<SsoTokenable> dataProtector,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
IOrganizationDomainRepository organizationDomainRepository,
|
||||
IRegisterUserCommand registerUserCommand)
|
||||
{
|
||||
_schemeProvider = schemeProvider;
|
||||
_clientStore = clientStore;
|
||||
@ -90,6 +93,7 @@ public class AccountController : Controller
|
||||
_globalSettings = globalSettings;
|
||||
_dataProtector = dataProtector;
|
||||
_organizationDomainRepository = organizationDomainRepository;
|
||||
_registerUserCommand = registerUserCommand;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@ -538,7 +542,7 @@ public class AccountController : Controller
|
||||
EmailVerified = emailVerified,
|
||||
ApiKey = CoreHelpers.SecureRandomString(30)
|
||||
};
|
||||
await _userService.RegisterUserAsync(user);
|
||||
await _registerUserCommand.RegisterUser(user);
|
||||
|
||||
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
|
||||
var twoFactorPolicy =
|
||||
|
1315
bitwarden_license/src/Sso/package-lock.json
generated
1315
bitwarden_license/src/Sso/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -556,7 +556,6 @@ public class ProviderBillingServiceTests
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Coupon == "msp-discount-35" &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
@ -579,7 +578,6 @@ public class ProviderBillingServiceTests
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Coupon == "msp-discount-35" &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
@ -731,10 +729,13 @@ public class ProviderBillingServiceTests
|
||||
string invoiceId,
|
||||
SutProvider<ProviderBillingService> sutProvider)
|
||||
{
|
||||
var clientId = Guid.NewGuid();
|
||||
|
||||
var invoiceItems = new List<ProviderInvoiceItem>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
ClientId = clientId,
|
||||
ClientName = "Client 1",
|
||||
AssignedSeats = 50,
|
||||
UsedSeats = 30,
|
||||
@ -759,6 +760,7 @@ public class ProviderBillingServiceTests
|
||||
|
||||
var record = records.First();
|
||||
|
||||
Assert.Equal(clientId.ToString(), record.Id);
|
||||
Assert.Equal("Client 1", record.Client);
|
||||
Assert.Equal(50, record.Assigned);
|
||||
Assert.Equal(30, record.Used);
|
||||
@ -857,13 +859,16 @@ public class ProviderBillingServiceTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetConsolidatedBillingSubscription_Success(
|
||||
public async Task GetConsolidatedBillingSubscription_Active_NoSuspension_Success(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
{
|
||||
var subscriberService = sutProvider.GetDependency<ISubscriberService>();
|
||||
|
||||
var subscription = new Subscription();
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = "active"
|
||||
};
|
||||
|
||||
subscriberService.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(
|
||||
options => options.Expand.Count == 2 && options.Expand.First() == "customer" && options.Expand.Last() == "test_clock")).Returns(subscription);
|
||||
@ -894,26 +899,33 @@ public class ProviderBillingServiceTests
|
||||
|
||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||
|
||||
var consolidatedBillingSubscription = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider);
|
||||
var taxInformation =
|
||||
new TaxInformationDTO("US", "12345", "123456789", "123 Example St.", null, "Example Town", "NY");
|
||||
|
||||
Assert.NotNull(consolidatedBillingSubscription);
|
||||
subscriberService.GetTaxInformation(provider).Returns(taxInformation);
|
||||
|
||||
Assert.Equivalent(consolidatedBillingSubscription.Subscription, subscription);
|
||||
var (gotProviderPlans, gotSubscription, gotTaxInformation, gotSuspension) = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider);
|
||||
|
||||
Assert.Equal(2, consolidatedBillingSubscription.ProviderPlans.Count);
|
||||
Assert.Equal(2, gotProviderPlans.Count);
|
||||
|
||||
var configuredEnterprisePlan =
|
||||
consolidatedBillingSubscription.ProviderPlans.FirstOrDefault(configuredPlan =>
|
||||
gotProviderPlans.FirstOrDefault(configuredPlan =>
|
||||
configuredPlan.PlanType == PlanType.EnterpriseMonthly);
|
||||
|
||||
var configuredTeamsPlan =
|
||||
consolidatedBillingSubscription.ProviderPlans.FirstOrDefault(configuredPlan =>
|
||||
gotProviderPlans.FirstOrDefault(configuredPlan =>
|
||||
configuredPlan.PlanType == PlanType.TeamsMonthly);
|
||||
|
||||
Compare(enterprisePlan, configuredEnterprisePlan);
|
||||
|
||||
Compare(teamsPlan, configuredTeamsPlan);
|
||||
|
||||
Assert.Equivalent(subscription, gotSubscription);
|
||||
|
||||
Assert.Equivalent(taxInformation, gotTaxInformation);
|
||||
|
||||
Assert.Null(gotSuspension);
|
||||
|
||||
return;
|
||||
|
||||
void Compare(ProviderPlan providerPlan, ConfiguredProviderPlanDTO configuredProviderPlan)
|
||||
@ -927,6 +939,83 @@ public class ProviderBillingServiceTests
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetConsolidatedBillingSubscription_PastDue_HasSuspension_Success(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
{
|
||||
var subscriberService = sutProvider.GetDependency<ISubscriberService>();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "subscription_id",
|
||||
Status = "past_due",
|
||||
CollectionMethod = "send_invoice"
|
||||
};
|
||||
|
||||
subscriberService.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(
|
||||
options => options.Expand.Count == 2 && options.Expand.First() == "customer" && options.Expand.Last() == "test_clock")).Returns(subscription);
|
||||
|
||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||
|
||||
var enterprisePlan = new ProviderPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
};
|
||||
|
||||
var teamsPlan = new ProviderPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
SeatMinimum = 50,
|
||||
PurchasedSeats = 10,
|
||||
AllocatedSeats = 60
|
||||
};
|
||||
|
||||
var providerPlans = new List<ProviderPlan> { enterprisePlan, teamsPlan, };
|
||||
|
||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||
|
||||
var taxInformation =
|
||||
new TaxInformationDTO("US", "12345", "123456789", "123 Example St.", null, "Example Town", "NY");
|
||||
|
||||
subscriberService.GetTaxInformation(provider).Returns(taxInformation);
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
var openInvoice = new Invoice
|
||||
{
|
||||
Id = "invoice_id",
|
||||
Status = "open",
|
||||
DueDate = new DateTime(2024, 6, 1),
|
||||
Created = new DateTime(2024, 5, 1),
|
||||
PeriodEnd = new DateTime(2024, 6, 1)
|
||||
};
|
||||
|
||||
stripeAdapter.InvoiceSearchAsync(Arg.Is<InvoiceSearchOptions>(options =>
|
||||
options.Query == $"subscription:'{subscription.Id}' status:'open'"))
|
||||
.Returns([openInvoice]);
|
||||
|
||||
var (gotProviderPlans, gotSubscription, gotTaxInformation, gotSuspension) = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider);
|
||||
|
||||
Assert.Equal(2, gotProviderPlans.Count);
|
||||
|
||||
Assert.Equivalent(subscription, gotSubscription);
|
||||
|
||||
Assert.Equivalent(taxInformation, gotTaxInformation);
|
||||
|
||||
Assert.NotNull(gotSuspension);
|
||||
Assert.Equal(openInvoice.DueDate.Value.AddDays(30), gotSuspension.SuspensionDate);
|
||||
Assert.Equal(openInvoice.PeriodEnd, gotSuspension.UnpaidPeriodEndDate);
|
||||
Assert.Equal(30, gotSuspension.GracePeriod);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StartSubscription
|
||||
@ -936,16 +1025,6 @@ public class ProviderBillingServiceTests
|
||||
SutProvider<ProviderBillingService> sutProvider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.StartSubscription(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_AlreadyHasGatewaySubscriptionId_ContactSupport(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
{
|
||||
provider.GatewaySubscriptionId = "subscription_id";
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_NoProviderPlans_ContactSupport(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
@ -1034,8 +1113,24 @@ public class ProviderBillingServiceTests
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 100 },
|
||||
new() { PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 100 }
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||
@ -1066,8 +1161,24 @@ public class ProviderBillingServiceTests
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 100 },
|
||||
new() { PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 100 }
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||
|
@ -18,13 +18,21 @@ public class RevokeAccessTokenCommandTests
|
||||
var apiKey1 = new ApiKey
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServiceAccountId = serviceAccount.Id
|
||||
ServiceAccountId = serviceAccount.Id,
|
||||
Name = "Test Name",
|
||||
Scope = "Test Scope",
|
||||
EncryptedPayload = "Test EncryptedPayload",
|
||||
Key = "Test Key",
|
||||
};
|
||||
|
||||
var apiKey2 = new ApiKey
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServiceAccountId = serviceAccount.Id
|
||||
ServiceAccountId = serviceAccount.Id,
|
||||
Name = "Test Name",
|
||||
Scope = "Test Scope",
|
||||
EncryptedPayload = "Test EncryptedPayload",
|
||||
Key = "Test Key",
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IApiKeyRepository>()
|
||||
|
@ -201,7 +201,14 @@ public class ScimApplicationFactory : WebApplicationFactoryBase<Startup>
|
||||
{
|
||||
return new List<Organization>()
|
||||
{
|
||||
new Organization { Id = TestOrganizationId1, Name = "Test Organization 1", UseGroups = true }
|
||||
new Organization
|
||||
{
|
||||
Id = TestOrganizationId1,
|
||||
Name = "Test Organization 1",
|
||||
BillingEmail = $"billing-email+{TestOrganizationId1}@example.com",
|
||||
UseGroups = true,
|
||||
Plan = "Enterprise",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -27,4 +27,17 @@
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Modified</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@Model.User.RevisionDate.ToString()</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Last Email Address Change</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastEmailChangeDate.ToString() ?? "-")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Last KDF Change</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastKdfChangeDate.ToString() ?? "-")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Last Key Rotation</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastKeyRotationDate.ToString() ?? "-")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Last Password Change</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastPasswordChangeDate.ToString() ?? "-")</dd>
|
||||
|
||||
</dl>
|
||||
|
1315
src/Admin/package-lock.json
generated
1315
src/Admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,6 +10,8 @@ using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -46,6 +48,7 @@ public class OrganizationUsersController : Controller
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
|
||||
public OrganizationUsersController(
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -63,7 +66,8 @@ public class OrganizationUsersController : Controller
|
||||
IAcceptOrgUserCommand acceptOrgUserCommand,
|
||||
IAuthorizationService authorizationService,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IFeatureService featureService)
|
||||
IFeatureService featureService,
|
||||
ISsoConfigRepository ssoConfigRepository)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@ -81,6 +85,7 @@ public class OrganizationUsersController : Controller
|
||||
_authorizationService = authorizationService;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_featureService = featureService;
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@ -456,6 +461,13 @@ public class OrganizationUsersController : Controller
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
|
||||
var isTdeEnrollment = ssoConfig != null && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption;
|
||||
if (!isTdeEnrollment && !string.IsNullOrWhiteSpace(model.ResetPasswordKey) && !await _userService.VerifySecretAsync(user, model.MasterPasswordHash))
|
||||
{
|
||||
throw new BadRequestException("Incorrect password");
|
||||
}
|
||||
|
||||
var callingUserId = user.Id;
|
||||
await _organizationService.UpdateUserResetPasswordEnrollmentAsync(
|
||||
orgId, userId, model.ResetPasswordKey, callingUserId);
|
||||
|
@ -101,6 +101,7 @@ public class OrganizationUserUpdateRequestModel
|
||||
public class OrganizationUserResetPasswordEnrollmentRequestModel
|
||||
{
|
||||
public string ResetPasswordKey { get; set; }
|
||||
public string MasterPasswordHash { get; set; }
|
||||
}
|
||||
|
||||
public class OrganizationUserBulkRequestModel
|
||||
|
@ -42,6 +42,8 @@ public class ProviderOrganizationResponseModel : ResponseModel
|
||||
RevisionDate = providerOrganization.RevisionDate;
|
||||
UserCount = providerOrganization.UserCount;
|
||||
Seats = providerOrganization.Seats;
|
||||
OccupiedSeats = providerOrganization.OccupiedSeats;
|
||||
RemainingSeats = providerOrganization.Seats - providerOrganization.OccupiedSeats;
|
||||
Plan = providerOrganization.Plan;
|
||||
}
|
||||
|
||||
@ -54,6 +56,8 @@ public class ProviderOrganizationResponseModel : ResponseModel
|
||||
public DateTime RevisionDate { get; set; }
|
||||
public int UserCount { get; set; }
|
||||
public int? Seats { get; set; }
|
||||
public int? OccupiedSeats { get; set; }
|
||||
public int? RemainingSeats { get; set; }
|
||||
public string Plan { get; set; }
|
||||
}
|
||||
|
||||
|
@ -8,11 +8,11 @@ public record ConsolidatedBillingSubscriptionResponse(
|
||||
DateTime CurrentPeriodEndDate,
|
||||
decimal? DiscountPercentage,
|
||||
string CollectionMethod,
|
||||
DateTime? UnpaidPeriodEndDate,
|
||||
int? GracePeriod,
|
||||
DateTime? SuspensionDate,
|
||||
IEnumerable<ProviderPlanResponse> Plans,
|
||||
long AccountCredit,
|
||||
TaxInformationDTO TaxInformation,
|
||||
DateTime? CancelAt,
|
||||
IEnumerable<ProviderPlanResponse> Plans)
|
||||
SubscriptionSuspensionDTO Suspension)
|
||||
{
|
||||
private const string _annualCadence = "Annual";
|
||||
private const string _monthlyCadence = "Monthly";
|
||||
@ -20,9 +20,9 @@ public record ConsolidatedBillingSubscriptionResponse(
|
||||
public static ConsolidatedBillingSubscriptionResponse From(
|
||||
ConsolidatedBillingSubscriptionDTO consolidatedBillingSubscription)
|
||||
{
|
||||
var (providerPlans, subscription, suspensionDate, unpaidPeriodEndDate) = consolidatedBillingSubscription;
|
||||
var (providerPlans, subscription, taxInformation, suspension) = consolidatedBillingSubscription;
|
||||
|
||||
var providerPlansDTO = providerPlans
|
||||
var providerPlanResponses = providerPlans
|
||||
.Select(providerPlan =>
|
||||
{
|
||||
var plan = StaticStore.GetPlan(providerPlan.PlanType);
|
||||
@ -37,18 +37,16 @@ public record ConsolidatedBillingSubscriptionResponse(
|
||||
cadence);
|
||||
});
|
||||
|
||||
var gracePeriod = subscription.CollectionMethod == "charge_automatically" ? 14 : 30;
|
||||
|
||||
return new ConsolidatedBillingSubscriptionResponse(
|
||||
subscription.Status,
|
||||
subscription.CurrentPeriodEnd,
|
||||
subscription.Customer?.Discount?.Coupon?.PercentOff,
|
||||
subscription.CollectionMethod,
|
||||
unpaidPeriodEndDate,
|
||||
gracePeriod,
|
||||
suspensionDate,
|
||||
providerPlanResponses,
|
||||
subscription.Customer?.Balance ?? 0,
|
||||
taxInformation,
|
||||
subscription.CancelAt,
|
||||
providerPlansDTO);
|
||||
suspension);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -19,8 +19,7 @@ public record InvoiceDTO(
|
||||
decimal Total,
|
||||
string Status,
|
||||
DateTime? DueDate,
|
||||
string Url,
|
||||
string PdfUrl)
|
||||
string Url)
|
||||
{
|
||||
public static InvoiceDTO From(Invoice invoice) => new(
|
||||
invoice.Id,
|
||||
@ -29,6 +28,5 @@ public record InvoiceDTO(
|
||||
invoice.Total / 100M,
|
||||
invoice.Status,
|
||||
invoice.DueDate,
|
||||
invoice.HostedInvoiceUrl,
|
||||
invoice.InvoicePdf);
|
||||
invoice.HostedInvoiceUrl);
|
||||
}
|
||||
|
@ -1,9 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.SecretsManager.Models.Request;
|
||||
|
||||
public class GetSecretsRequestModel
|
||||
public class GetSecretsRequestModel : IValidatableObject
|
||||
{
|
||||
[Required]
|
||||
public IEnumerable<Guid> Ids { get; set; }
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
var isDistinct = Ids.Distinct().Count() == Ids.Count();
|
||||
if (!isDistinct)
|
||||
{
|
||||
var duplicateGuids = Ids.GroupBy(x => x)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key);
|
||||
|
||||
yield return new ValidationResult(
|
||||
$"The following GUIDs were duplicated {string.Join(", ", duplicateGuids)} ",
|
||||
new[] { nameof(GetSecretsRequestModel) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -117,7 +117,7 @@ public class CiphersController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections);
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id);
|
||||
return new CipherDetailsResponseModel(cipher, _globalSettings, collectionCiphers);
|
||||
}
|
||||
|
||||
@ -131,7 +131,7 @@ public class CiphersController : Controller
|
||||
Dictionary<Guid, IGrouping<Guid, CollectionCipher>> collectionCiphersGroupDict = null;
|
||||
if (hasOrgs)
|
||||
{
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdAsync(userId, UseFlexibleCollections);
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdAsync(userId);
|
||||
collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
|
||||
}
|
||||
|
||||
@ -202,7 +202,7 @@ public class CiphersController : Controller
|
||||
ValidateClientVersionForItemLevelEncryptionSupport(cipher);
|
||||
ValidateClientVersionForFido2CredentialSupport(cipher);
|
||||
|
||||
var collectionIds = (await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections)).Select(c => c.CollectionId).ToList();
|
||||
var collectionIds = (await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id)).Select(c => c.CollectionId).ToList();
|
||||
var modelOrgId = string.IsNullOrWhiteSpace(model.OrganizationId) ?
|
||||
(Guid?)null : new Guid(model.OrganizationId);
|
||||
if (cipher.OrganizationId != modelOrgId)
|
||||
@ -233,7 +233,7 @@ public class CiphersController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var collectionIds = (await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections)).Select(c => c.CollectionId).ToList();
|
||||
var collectionIds = (await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id)).Select(c => c.CollectionId).ToList();
|
||||
// object cannot be a descendant of CipherDetails, so let's clone it.
|
||||
var cipherClone = model.ToCipher(cipher).Clone();
|
||||
await _cipherService.SaveAsync(cipherClone, userId, model.LastKnownRevisionDate, collectionIds, true, false);
|
||||
@ -618,7 +618,7 @@ public class CiphersController : Controller
|
||||
model.CollectionIds.Select(c => new Guid(c)), userId, false);
|
||||
|
||||
var updatedCipher = await GetByIdAsync(id, userId);
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections);
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id);
|
||||
|
||||
return new CipherDetailsResponseModel(updatedCipher, _globalSettings, collectionCiphers);
|
||||
}
|
||||
@ -639,7 +639,7 @@ public class CiphersController : Controller
|
||||
model.CollectionIds.Select(c => new Guid(c)), userId, false);
|
||||
|
||||
var updatedCipher = await GetByIdAsync(id, userId);
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections);
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id);
|
||||
// If a user removes the last Can Manage access of a cipher, the "updatedCipher" will return null
|
||||
// We will be returning an "Unavailable" property so the client knows the user can no longer access this
|
||||
var response = new OptionalCipherDetailsResponseModel()
|
||||
@ -1261,7 +1261,7 @@ public class CiphersController : Controller
|
||||
|
||||
private async Task<CipherDetails> GetByIdAsync(Guid cipherId, Guid userId)
|
||||
{
|
||||
return await _cipherRepository.GetByIdAsync(cipherId, userId, UseFlexibleCollections);
|
||||
return await _cipherRepository.GetByIdAsync(cipherId, userId);
|
||||
}
|
||||
|
||||
private bool UseFlexibleCollectionsV1()
|
||||
|
@ -91,7 +91,7 @@ public class SyncController : Controller
|
||||
if (hasEnabledOrgs)
|
||||
{
|
||||
collections = await _collectionRepository.GetManyByUserIdAsync(user.Id, UseFlexibleCollections);
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdAsync(user.Id, UseFlexibleCollections);
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdAsync(user.Id);
|
||||
collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
|
||||
}
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
12
src/Billing/Services/IStripeEventProcessor.cs
Normal file
12
src/Billing/Services/IStripeEventProcessor.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using Event = Stripe.Event;
|
||||
namespace Bit.Billing.Services;
|
||||
|
||||
public interface IStripeEventProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes the specified Stripe event asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent">The Stripe event to be processed.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task ProcessEventAsync(Event parsedEvent);
|
||||
}
|
67
src/Billing/Services/IStripeEventUtilityService.cs
Normal file
67
src/Billing/Services/IStripeEventUtilityService.cs
Normal file
@ -0,0 +1,67 @@
|
||||
using Stripe;
|
||||
using Transaction = Bit.Core.Entities.Transaction;
|
||||
namespace Bit.Billing.Services;
|
||||
|
||||
public interface IStripeEventUtilityService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the organization or user ID from the metadata of a Stripe Charge object.
|
||||
/// </summary>
|
||||
/// <param name="charge"></param>
|
||||
/// <returns></returns>
|
||||
Task<(Guid?, Guid?, Guid?)> GetEntityIdsFromChargeAsync(Charge charge);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the organizationId, userId, or providerId from the metadata of a Stripe Subscription object.
|
||||
/// </summary>
|
||||
/// <param name="metadata"></param>
|
||||
/// <returns></returns>
|
||||
Tuple<Guid?, Guid?, Guid?> GetIdsFromMetadata(Dictionary<string, string> metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the specified subscription is a sponsored subscription.
|
||||
/// </summary>
|
||||
/// <param name="subscription">The subscription to be evaluated.</param>
|
||||
/// <returns>
|
||||
/// A boolean value indicating whether the subscription is a sponsored subscription.
|
||||
/// Returns <c>true</c> if the subscription matches any of the sponsored plans; otherwise, <c>false</c>.
|
||||
/// </returns>
|
||||
bool IsSponsoredSubscription(Subscription subscription);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Stripe Charge object to a Bitwarden Transaction object.
|
||||
/// </summary>
|
||||
/// <param name="charge"></param>
|
||||
/// <param name="organizationId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// /// <param name="providerId"></param>
|
||||
/// <returns></returns>
|
||||
Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to pay the specified invoice. If a customer is eligible, the invoice is paid using Braintree or Stripe.
|
||||
/// </summary>
|
||||
/// <param name="invoice">The invoice to be paid.</param>
|
||||
/// <param name="attemptToPayWithStripe">Indicates whether to attempt payment with Stripe. Defaults to false.</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result contains a boolean value indicating whether the invoice payment attempt was successful.</returns>
|
||||
Task<bool> AttemptToPayInvoiceAsync(Invoice invoice, bool attemptToPayWithStripe = false);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an invoice should be attempted to be paid based on certain criteria.
|
||||
/// </summary>
|
||||
/// <param name="invoice">The invoice to be evaluated.</param>
|
||||
/// <returns>A boolean value indicating whether the invoice should be attempted to be paid.</returns>
|
||||
bool ShouldAttemptToPayInvoice(Invoice invoice);
|
||||
|
||||
/// <summary>
|
||||
/// The ID for the premium annual plan.
|
||||
/// </summary>
|
||||
const string PremiumPlanId = "premium-annually";
|
||||
|
||||
/// <summary>
|
||||
/// The ID for the premium annual plan via the App Store.
|
||||
/// </summary>
|
||||
const string PremiumPlanIdAppStore = "premium-annually-app";
|
||||
|
||||
}
|
67
src/Billing/Services/IStripeWebhookHandler.cs
Normal file
67
src/Billing/Services/IStripeWebhookHandler.cs
Normal file
@ -0,0 +1,67 @@
|
||||
using Event = Stripe.Event;
|
||||
namespace Bit.Billing.Services;
|
||||
|
||||
public interface IStripeWebhookHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the specified Stripe event asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent">The Stripe event to be handled.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task HandleAsync(Event parsedEvent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for handling Stripe subscription deleted events.
|
||||
/// </summary>
|
||||
public interface ISubscriptionDeletedHandler : IStripeWebhookHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for handling Stripe subscription updated events.
|
||||
/// </summary>
|
||||
public interface ISubscriptionUpdatedHandler : IStripeWebhookHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for handling Stripe upcoming invoice events.
|
||||
/// </summary>
|
||||
public interface IUpcomingInvoiceHandler : IStripeWebhookHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for handling Stripe charge succeeded events.
|
||||
/// </summary>
|
||||
public interface IChargeSucceededHandler : IStripeWebhookHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for handling Stripe charge refunded events.
|
||||
/// </summary>
|
||||
public interface IChargeRefundedHandler : IStripeWebhookHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for handling Stripe payment succeeded events.
|
||||
/// </summary>
|
||||
public interface IPaymentSucceededHandler : IStripeWebhookHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for handling Stripe payment failed events.
|
||||
/// </summary>
|
||||
public interface IPaymentFailedHandler : IStripeWebhookHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for handling Stripe invoice created events.
|
||||
/// </summary>
|
||||
public interface IInvoiceCreatedHandler : IStripeWebhookHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for handling Stripe payment method attached events.
|
||||
/// </summary>
|
||||
public interface IPaymentMethodAttachedHandler : IStripeWebhookHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for handling Stripe customer updated events.
|
||||
/// </summary>
|
||||
public interface ICustomerUpdatedHandler : IStripeWebhookHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for handling Stripe Invoice Finalized events.
|
||||
/// </summary>
|
||||
public interface IInvoiceFinalizedHandler : IStripeWebhookHandler;
|
@ -0,0 +1,98 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Event = Stripe.Event;
|
||||
using Transaction = Bit.Core.Entities.Transaction;
|
||||
using TransactionType = Bit.Core.Enums.TransactionType;
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class ChargeRefundedHandler : IChargeRefundedHandler
|
||||
{
|
||||
private readonly ILogger<ChargeRefundedHandler> _logger;
|
||||
private readonly IStripeEventService _stripeEventService;
|
||||
private readonly ITransactionRepository _transactionRepository;
|
||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||
|
||||
public ChargeRefundedHandler(
|
||||
ILogger<ChargeRefundedHandler> logger,
|
||||
IStripeEventService stripeEventService,
|
||||
ITransactionRepository transactionRepository,
|
||||
IStripeEventUtilityService stripeEventUtilityService)
|
||||
{
|
||||
_logger = logger;
|
||||
_stripeEventService = stripeEventService;
|
||||
_transactionRepository = transactionRepository;
|
||||
_stripeEventUtilityService = stripeEventUtilityService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="HandledStripeWebhook.ChargeRefunded"/> event type from Stripe.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent"></param>
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var charge = await _stripeEventService.GetCharge(parsedEvent, true, ["refunds"]);
|
||||
var parentTransaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.Stripe, charge.Id);
|
||||
if (parentTransaction == null)
|
||||
{
|
||||
// Attempt to create a transaction for the charge if it doesn't exist
|
||||
var (organizationId, userId, providerId) = await _stripeEventUtilityService.GetEntityIdsFromChargeAsync(charge);
|
||||
var tx = _stripeEventUtilityService.FromChargeToTransaction(charge, organizationId, userId, providerId);
|
||||
try
|
||||
{
|
||||
parentTransaction = await _transactionRepository.CreateAsync(tx);
|
||||
}
|
||||
catch (SqlException e) when (e.Number == 547) // FK constraint violation
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Charge refund could not create transaction as entity may have been deleted. {ChargeId}",
|
||||
charge.Id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var amountRefunded = charge.AmountRefunded / 100M;
|
||||
|
||||
if (parentTransaction.Refunded.GetValueOrDefault() ||
|
||||
parentTransaction.RefundedAmount.GetValueOrDefault() >= amountRefunded)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Charge refund amount doesn't match parent transaction's amount or parent has already been refunded. {ChargeId}",
|
||||
charge.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
parentTransaction.RefundedAmount = amountRefunded;
|
||||
if (charge.Refunded)
|
||||
{
|
||||
parentTransaction.Refunded = true;
|
||||
}
|
||||
|
||||
await _transactionRepository.ReplaceAsync(parentTransaction);
|
||||
|
||||
foreach (var refund in charge.Refunds)
|
||||
{
|
||||
var refundTransaction = await _transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.Stripe, refund.Id);
|
||||
if (refundTransaction != null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await _transactionRepository.CreateAsync(new Transaction
|
||||
{
|
||||
Amount = refund.Amount / 100M,
|
||||
CreationDate = refund.Created,
|
||||
OrganizationId = parentTransaction.OrganizationId,
|
||||
UserId = parentTransaction.UserId,
|
||||
ProviderId = parentTransaction.ProviderId,
|
||||
Type = TransactionType.Refund,
|
||||
Gateway = GatewayType.Stripe,
|
||||
GatewayId = refund.Id,
|
||||
PaymentMethodType = parentTransaction.PaymentMethodType,
|
||||
Details = parentTransaction.Details
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class ChargeSucceededHandler : IChargeSucceededHandler
|
||||
{
|
||||
private readonly ILogger<ChargeSucceededHandler> _logger;
|
||||
private readonly IStripeEventService _stripeEventService;
|
||||
private readonly ITransactionRepository _transactionRepository;
|
||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||
|
||||
public ChargeSucceededHandler(
|
||||
ILogger<ChargeSucceededHandler> logger,
|
||||
IStripeEventService stripeEventService,
|
||||
ITransactionRepository transactionRepository,
|
||||
IStripeEventUtilityService stripeEventUtilityService)
|
||||
{
|
||||
_logger = logger;
|
||||
_stripeEventService = stripeEventService;
|
||||
_transactionRepository = transactionRepository;
|
||||
_stripeEventUtilityService = stripeEventUtilityService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="HandledStripeWebhook.ChargeSucceeded"/> event type from Stripe.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent"></param>
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var charge = await _stripeEventService.GetCharge(parsedEvent);
|
||||
var existingTransaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.Stripe, charge.Id);
|
||||
if (existingTransaction is not null)
|
||||
{
|
||||
_logger.LogInformation("Charge success already processed. {ChargeId}", charge.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var (organizationId, userId, providerId) = await _stripeEventUtilityService.GetEntityIdsFromChargeAsync(charge);
|
||||
if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue)
|
||||
{
|
||||
_logger.LogWarning("Charge success has no subscriber ids. {ChargeId}", charge.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var transaction = _stripeEventUtilityService.FromChargeToTransaction(charge, organizationId, userId, providerId);
|
||||
if (!transaction.PaymentMethodType.HasValue)
|
||||
{
|
||||
_logger.LogWarning("Charge success from unsupported source/method. {ChargeId}", charge.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _transactionRepository.CreateAsync(transaction);
|
||||
}
|
||||
catch (SqlException e) when (e.Number == 547)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Charge success could not create transaction as entity may have been deleted. {ChargeId}",
|
||||
charge.Id);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Business;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class CustomerUpdatedHandler : ICustomerUpdatedHandler
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IStripeEventService _stripeEventService;
|
||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||
|
||||
public CustomerUpdatedHandler(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IReferenceEventService referenceEventService,
|
||||
ICurrentContext currentContext,
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeEventUtilityService stripeEventUtilityService)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_referenceEventService = referenceEventService;
|
||||
_currentContext = currentContext;
|
||||
_stripeEventService = stripeEventService;
|
||||
_stripeEventUtilityService = stripeEventUtilityService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="HandledStripeWebhook.CustomerUpdated"/> event type from Stripe.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent"></param>
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var customer = await _stripeEventService.GetCustomer(parsedEvent, true, ["subscriptions"]);
|
||||
if (customer.Subscriptions == null || !customer.Subscriptions.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var subscription = customer.Subscriptions.First();
|
||||
|
||||
var (organizationId, _, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
|
||||
|
||||
if (!organizationId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
|
||||
organization.BillingEmail = customer.Email;
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
await _referenceEventService.RaiseEventAsync(
|
||||
new ReferenceEvent(ReferenceEventType.OrganizationEditedInStripe, organization, _currentContext));
|
||||
}
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class InvoiceCreatedHandler : IInvoiceCreatedHandler
|
||||
{
|
||||
private readonly IStripeEventService _stripeEventService;
|
||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||
private readonly IProviderEventService _providerEventService;
|
||||
|
||||
public InvoiceCreatedHandler(
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeEventUtilityService stripeEventUtilityService,
|
||||
IProviderEventService providerEventService)
|
||||
{
|
||||
_stripeEventService = stripeEventService;
|
||||
_stripeEventUtilityService = stripeEventUtilityService;
|
||||
_providerEventService = providerEventService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="HandledStripeWebhook.InvoiceCreated"/> event type from Stripe.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent"></param>
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
||||
if (_stripeEventUtilityService.ShouldAttemptToPayInvoice(invoice))
|
||||
{
|
||||
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
|
||||
}
|
||||
|
||||
await _providerEventService.TryRecordInvoiceLineItems(parsedEvent);
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class InvoiceFinalizedHandler : IInvoiceFinalizedHandler
|
||||
{
|
||||
|
||||
private readonly IProviderEventService _providerEventService;
|
||||
|
||||
public InvoiceFinalizedHandler(IProviderEventService providerEventService)
|
||||
{
|
||||
_providerEventService = providerEventService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="HandledStripeWebhook.InvoiceFinalized"/> event type from Stripe.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent"></param>
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
await _providerEventService.TryRecordInvoiceLineItems(parsedEvent);
|
||||
}
|
||||
}
|
52
src/Billing/Services/Implementations/PaymentFailedHandler.cs
Normal file
52
src/Billing/Services/Implementations/PaymentFailedHandler.cs
Normal file
@ -0,0 +1,52 @@
|
||||
using Stripe;
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class PaymentFailedHandler : IPaymentFailedHandler
|
||||
{
|
||||
private readonly IStripeEventService _stripeEventService;
|
||||
private readonly IStripeFacade _stripeFacade;
|
||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||
|
||||
public PaymentFailedHandler(
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeFacade stripeFacade,
|
||||
IStripeEventUtilityService stripeEventUtilityService)
|
||||
{
|
||||
_stripeEventService = stripeEventService;
|
||||
_stripeFacade = stripeFacade;
|
||||
_stripeEventUtilityService = stripeEventUtilityService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="HandledStripeWebhook.PaymentFailed"/> event type from Stripe.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent"></param>
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
||||
if (invoice.Paid || invoice.AttemptCount <= 1 || !ShouldAttemptToPayInvoice(invoice))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
|
||||
// attempt count 4 = 11 days after initial failure
|
||||
if (invoice.AttemptCount <= 3 ||
|
||||
!subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore))
|
||||
{
|
||||
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldAttemptToPayInvoice(Invoice invoice) =>
|
||||
invoice is
|
||||
{
|
||||
AmountDue: > 0,
|
||||
Paid: false,
|
||||
CollectionMethod: "charge_automatically",
|
||||
BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice",
|
||||
SubscriptionId: not null
|
||||
};
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Stripe;
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler
|
||||
{
|
||||
private readonly ILogger<PaymentMethodAttachedHandler> _logger;
|
||||
private readonly IStripeEventService _stripeEventService;
|
||||
private readonly IStripeFacade _stripeFacade;
|
||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||
|
||||
public PaymentMethodAttachedHandler(
|
||||
ILogger<PaymentMethodAttachedHandler> logger,
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeFacade stripeFacade,
|
||||
IStripeEventUtilityService stripeEventUtilityService)
|
||||
{
|
||||
_logger = logger;
|
||||
_stripeEventService = stripeEventService;
|
||||
_stripeFacade = stripeFacade;
|
||||
_stripeEventUtilityService = stripeEventUtilityService;
|
||||
}
|
||||
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent);
|
||||
if (paymentMethod is null)
|
||||
{
|
||||
_logger.LogWarning("Attempted to handle the event payment_method.attached but paymentMethod was null");
|
||||
return;
|
||||
}
|
||||
|
||||
var subscriptionListOptions = new SubscriptionListOptions
|
||||
{
|
||||
Customer = paymentMethod.CustomerId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
Expand = ["data.latest_invoice"]
|
||||
};
|
||||
|
||||
StripeList<Subscription> unpaidSubscriptions;
|
||||
try
|
||||
{
|
||||
unpaidSubscriptions = await _stripeFacade.ListSubscriptions(subscriptionListOptions);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e,
|
||||
"Attempted to get unpaid invoices for customer {CustomerId} but encountered an error while calling Stripe",
|
||||
paymentMethod.CustomerId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var unpaidSubscription in unpaidSubscriptions)
|
||||
{
|
||||
await AttemptToPayOpenSubscriptionAsync(unpaidSubscription);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AttemptToPayOpenSubscriptionAsync(Subscription unpaidSubscription)
|
||||
{
|
||||
var latestInvoice = unpaidSubscription.LatestInvoice;
|
||||
|
||||
if (unpaidSubscription.LatestInvoice is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attempted to pay unpaid subscription {SubscriptionId} but latest invoice didn't exist",
|
||||
unpaidSubscription.Id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (latestInvoice.Status != StripeInvoiceStatus.Open)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attempted to pay unpaid subscription {SubscriptionId} but latest invoice wasn't \"open\"",
|
||||
unpaidSubscription.Id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(latestInvoice, true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e,
|
||||
"Attempted to pay open invoice {InvoiceId} on unpaid subscription {SubscriptionId} but encountered an error",
|
||||
latestInvoice.Id, unpaidSubscription.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
171
src/Billing/Services/Implementations/PaymentSucceededHandler.cs
Normal file
171
src/Billing/Services/Implementations/PaymentSucceededHandler.cs
Normal file
@ -0,0 +1,171 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Business;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class PaymentSucceededHandler : IPaymentSucceededHandler
|
||||
{
|
||||
private readonly ILogger<PaymentSucceededHandler> _logger;
|
||||
private readonly IStripeEventService _stripeEventService;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IStripeFacade _stripeFacade;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||
|
||||
public PaymentSucceededHandler(
|
||||
ILogger<PaymentSucceededHandler> logger,
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeFacade stripeFacade,
|
||||
IProviderRepository providerRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IReferenceEventService referenceEventService,
|
||||
ICurrentContext currentContext,
|
||||
IUserRepository userRepository,
|
||||
IStripeEventUtilityService stripeEventUtilityService,
|
||||
IUserService userService,
|
||||
IOrganizationService organizationService)
|
||||
{
|
||||
_logger = logger;
|
||||
_stripeEventService = stripeEventService;
|
||||
_stripeFacade = stripeFacade;
|
||||
_providerRepository = providerRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_referenceEventService = referenceEventService;
|
||||
_currentContext = currentContext;
|
||||
_userRepository = userRepository;
|
||||
_stripeEventUtilityService = stripeEventUtilityService;
|
||||
_userService = userService;
|
||||
_organizationService = organizationService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="HandledStripeWebhook.PaymentSucceeded"/> event type from Stripe.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent"></param>
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
||||
if (!invoice.Paid || invoice.BillingReason != "subscription_create")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
|
||||
if (subscription?.Status != StripeSubscriptionStatus.Active)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - invoice.Created < TimeSpan.FromMinutes(1))
|
||||
{
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
|
||||
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
|
||||
|
||||
if (providerId.HasValue)
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Received invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) that does not exist",
|
||||
parsedEvent.Id,
|
||||
providerId.Value);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var teamsMonthly = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
|
||||
var enterpriseMonthly = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
||||
|
||||
var teamsMonthlyLineItem =
|
||||
subscription.Items.Data.FirstOrDefault(item =>
|
||||
item.Plan.Id == teamsMonthly.PasswordManager.StripeSeatPlanId);
|
||||
|
||||
var enterpriseMonthlyLineItem =
|
||||
subscription.Items.Data.FirstOrDefault(item =>
|
||||
item.Plan.Id == enterpriseMonthly.PasswordManager.StripeSeatPlanId);
|
||||
|
||||
if (teamsMonthlyLineItem == null || enterpriseMonthlyLineItem == null)
|
||||
{
|
||||
_logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items",
|
||||
parsedEvent.Id,
|
||||
provider.Id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent
|
||||
{
|
||||
Type = ReferenceEventType.Rebilled,
|
||||
Source = ReferenceEventSource.Provider,
|
||||
Id = provider.Id,
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
Seats = (int)teamsMonthlyLineItem.Quantity
|
||||
});
|
||||
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent
|
||||
{
|
||||
Type = ReferenceEventType.Rebilled,
|
||||
Source = ReferenceEventSource.Provider,
|
||||
Id = provider.Id,
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
Seats = (int)enterpriseMonthlyLineItem.Quantity
|
||||
});
|
||||
}
|
||||
else if (organizationId.HasValue)
|
||||
{
|
||||
if (!subscription.Items.Any(i =>
|
||||
StaticStore.Plans.Any(p => p.PasswordManager.StripePlanId == i.Plan.Id)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _organizationService.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
|
||||
|
||||
await _referenceEventService.RaiseEventAsync(
|
||||
new ReferenceEvent(ReferenceEventType.Rebilled, organization, _currentContext)
|
||||
{
|
||||
PlanName = organization?.Plan,
|
||||
PlanType = organization?.PlanType,
|
||||
Seats = organization?.Seats,
|
||||
Storage = organization?.MaxStorageGb,
|
||||
});
|
||||
}
|
||||
else if (userId.HasValue)
|
||||
{
|
||||
if (subscription.Items.All(i => i.Plan.Id != IStripeEventUtilityService.PremiumPlanId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
|
||||
|
||||
var user = await _userRepository.GetByIdAsync(userId.Value);
|
||||
await _referenceEventService.RaiseEventAsync(
|
||||
new ReferenceEvent(ReferenceEventType.Rebilled, user, _currentContext)
|
||||
{
|
||||
PlanName = IStripeEventUtilityService.PremiumPlanId,
|
||||
Storage = user?.MaxStorageGb,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -76,10 +76,11 @@ public class ProviderEventService(
|
||||
ProviderId = parsedProviderId,
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceNumber = invoice.Number,
|
||||
ClientId = client.Id,
|
||||
ClientName = client.OrganizationName,
|
||||
PlanName = client.Plan,
|
||||
AssignedSeats = client.Seats ?? 0,
|
||||
UsedSeats = client.UserCount,
|
||||
UsedSeats = client.OccupiedSeats ?? 0,
|
||||
Total = client.Plan == enterprisePlan.Name
|
||||
? (client.Seats ?? 0) * discountedEnterpriseSeatPrice
|
||||
: (client.Seats ?? 0) * discountedTeamsSeatPrice
|
||||
|
92
src/Billing/Services/Implementations/StripeEventProcessor.cs
Normal file
92
src/Billing/Services/Implementations/StripeEventProcessor.cs
Normal file
@ -0,0 +1,92 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class StripeEventProcessor : IStripeEventProcessor
|
||||
{
|
||||
private readonly ILogger<StripeEventProcessor> _logger;
|
||||
private readonly ISubscriptionDeletedHandler _subscriptionDeletedHandler;
|
||||
private readonly ISubscriptionUpdatedHandler _subscriptionUpdatedHandler;
|
||||
private readonly IUpcomingInvoiceHandler _upcomingInvoiceHandler;
|
||||
private readonly IChargeSucceededHandler _chargeSucceededHandler;
|
||||
private readonly IChargeRefundedHandler _chargeRefundedHandler;
|
||||
private readonly IPaymentSucceededHandler _paymentSucceededHandler;
|
||||
private readonly IPaymentFailedHandler _paymentFailedHandler;
|
||||
private readonly IInvoiceCreatedHandler _invoiceCreatedHandler;
|
||||
private readonly IPaymentMethodAttachedHandler _paymentMethodAttachedHandler;
|
||||
private readonly ICustomerUpdatedHandler _customerUpdatedHandler;
|
||||
private readonly IInvoiceFinalizedHandler _invoiceFinalizedHandler;
|
||||
|
||||
public StripeEventProcessor(
|
||||
ILogger<StripeEventProcessor> logger,
|
||||
ISubscriptionDeletedHandler subscriptionDeletedHandler,
|
||||
ISubscriptionUpdatedHandler subscriptionUpdatedHandler,
|
||||
IUpcomingInvoiceHandler upcomingInvoiceHandler,
|
||||
IChargeSucceededHandler chargeSucceededHandler,
|
||||
IChargeRefundedHandler chargeRefundedHandler,
|
||||
IPaymentSucceededHandler paymentSucceededHandler,
|
||||
IPaymentFailedHandler paymentFailedHandler,
|
||||
IInvoiceCreatedHandler invoiceCreatedHandler,
|
||||
IPaymentMethodAttachedHandler paymentMethodAttachedHandler,
|
||||
ICustomerUpdatedHandler customerUpdatedHandler,
|
||||
IInvoiceFinalizedHandler invoiceFinalizedHandler)
|
||||
{
|
||||
_logger = logger;
|
||||
_subscriptionDeletedHandler = subscriptionDeletedHandler;
|
||||
_subscriptionUpdatedHandler = subscriptionUpdatedHandler;
|
||||
_upcomingInvoiceHandler = upcomingInvoiceHandler;
|
||||
_chargeSucceededHandler = chargeSucceededHandler;
|
||||
_chargeRefundedHandler = chargeRefundedHandler;
|
||||
_paymentSucceededHandler = paymentSucceededHandler;
|
||||
_paymentFailedHandler = paymentFailedHandler;
|
||||
_invoiceCreatedHandler = invoiceCreatedHandler;
|
||||
_paymentMethodAttachedHandler = paymentMethodAttachedHandler;
|
||||
_customerUpdatedHandler = customerUpdatedHandler;
|
||||
_invoiceFinalizedHandler = invoiceFinalizedHandler;
|
||||
}
|
||||
|
||||
public async Task ProcessEventAsync(Event parsedEvent)
|
||||
{
|
||||
switch (parsedEvent.Type)
|
||||
{
|
||||
case HandledStripeWebhook.SubscriptionDeleted:
|
||||
await _subscriptionDeletedHandler.HandleAsync(parsedEvent);
|
||||
break;
|
||||
case HandledStripeWebhook.SubscriptionUpdated:
|
||||
await _subscriptionUpdatedHandler.HandleAsync(parsedEvent);
|
||||
break;
|
||||
case HandledStripeWebhook.UpcomingInvoice:
|
||||
await _upcomingInvoiceHandler.HandleAsync(parsedEvent);
|
||||
break;
|
||||
case HandledStripeWebhook.ChargeSucceeded:
|
||||
await _chargeSucceededHandler.HandleAsync(parsedEvent);
|
||||
break;
|
||||
case HandledStripeWebhook.ChargeRefunded:
|
||||
await _chargeRefundedHandler.HandleAsync(parsedEvent);
|
||||
break;
|
||||
case HandledStripeWebhook.PaymentSucceeded:
|
||||
await _paymentSucceededHandler.HandleAsync(parsedEvent);
|
||||
break;
|
||||
case HandledStripeWebhook.PaymentFailed:
|
||||
await _paymentFailedHandler.HandleAsync(parsedEvent);
|
||||
break;
|
||||
case HandledStripeWebhook.InvoiceCreated:
|
||||
await _invoiceCreatedHandler.HandleAsync(parsedEvent);
|
||||
break;
|
||||
case HandledStripeWebhook.PaymentMethodAttached:
|
||||
await _paymentMethodAttachedHandler.HandleAsync(parsedEvent);
|
||||
break;
|
||||
case HandledStripeWebhook.CustomerUpdated:
|
||||
await _customerUpdatedHandler.HandleAsync(parsedEvent);
|
||||
break;
|
||||
case HandledStripeWebhook.InvoiceFinalized:
|
||||
await _invoiceFinalizedHandler.HandleAsync(parsedEvent);
|
||||
break;
|
||||
default:
|
||||
_logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,401 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Braintree;
|
||||
using Stripe;
|
||||
using Customer = Stripe.Customer;
|
||||
using Subscription = Stripe.Subscription;
|
||||
using Transaction = Bit.Core.Entities.Transaction;
|
||||
using TransactionType = Bit.Core.Enums.TransactionType;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class StripeEventUtilityService : IStripeEventUtilityService
|
||||
{
|
||||
private readonly IStripeFacade _stripeFacade;
|
||||
private readonly ILogger<StripeEventUtilityService> _logger;
|
||||
private readonly ITransactionRepository _transactionRepository;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly BraintreeGateway _btGateway;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public StripeEventUtilityService(
|
||||
IStripeFacade stripeFacade,
|
||||
ILogger<StripeEventUtilityService> logger,
|
||||
ITransactionRepository transactionRepository,
|
||||
IMailService mailService,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
_stripeFacade = stripeFacade;
|
||||
_logger = logger;
|
||||
_transactionRepository = transactionRepository;
|
||||
_mailService = mailService;
|
||||
_btGateway = new BraintreeGateway
|
||||
{
|
||||
Environment = globalSettings.Braintree.Production ?
|
||||
Braintree.Environment.PRODUCTION : Braintree.Environment.SANDBOX,
|
||||
MerchantId = globalSettings.Braintree.MerchantId,
|
||||
PublicKey = globalSettings.Braintree.PublicKey,
|
||||
PrivateKey = globalSettings.Braintree.PrivateKey
|
||||
};
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the organizationId, userId, or providerId from the metadata of a Stripe Subscription object.
|
||||
/// </summary>
|
||||
/// <param name="metadata"></param>
|
||||
/// <returns></returns>
|
||||
public Tuple<Guid?, Guid?, Guid?> GetIdsFromMetadata(Dictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata == null || metadata.Count == 0)
|
||||
{
|
||||
return new Tuple<Guid?, Guid?, Guid?>(null, null, null);
|
||||
}
|
||||
|
||||
metadata.TryGetValue("organizationId", out var orgIdString);
|
||||
metadata.TryGetValue("userId", out var userIdString);
|
||||
metadata.TryGetValue("providerId", out var providerIdString);
|
||||
|
||||
orgIdString ??= metadata.FirstOrDefault(x =>
|
||||
x.Key.Equals("organizationId", StringComparison.OrdinalIgnoreCase)).Value;
|
||||
|
||||
userIdString ??= metadata.FirstOrDefault(x =>
|
||||
x.Key.Equals("userId", StringComparison.OrdinalIgnoreCase)).Value;
|
||||
|
||||
providerIdString ??= metadata.FirstOrDefault(x =>
|
||||
x.Key.Equals("providerId", StringComparison.OrdinalIgnoreCase)).Value;
|
||||
|
||||
Guid? organizationId = string.IsNullOrWhiteSpace(orgIdString) ? null : new Guid(orgIdString);
|
||||
Guid? userId = string.IsNullOrWhiteSpace(userIdString) ? null : new Guid(userIdString);
|
||||
Guid? providerId = string.IsNullOrWhiteSpace(providerIdString) ? null : new Guid(providerIdString);
|
||||
|
||||
return new Tuple<Guid?, Guid?, Guid?>(organizationId, userId, providerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the organization or user ID from the metadata of a Stripe Charge object.
|
||||
/// </summary>
|
||||
/// <param name="charge"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<(Guid?, Guid?, Guid?)> GetEntityIdsFromChargeAsync(Charge charge)
|
||||
{
|
||||
Guid? organizationId = null;
|
||||
Guid? userId = null;
|
||||
Guid? providerId = null;
|
||||
|
||||
if (charge.InvoiceId != null)
|
||||
{
|
||||
var invoice = await _stripeFacade.GetInvoice(charge.InvoiceId);
|
||||
if (invoice?.SubscriptionId != null)
|
||||
{
|
||||
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
|
||||
(organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
if (organizationId.HasValue || userId.HasValue || providerId.HasValue)
|
||||
{
|
||||
return (organizationId, userId, providerId);
|
||||
}
|
||||
|
||||
var subscriptions = await _stripeFacade.ListSubscriptions(new SubscriptionListOptions
|
||||
{
|
||||
Customer = charge.CustomerId
|
||||
});
|
||||
|
||||
foreach (var subscription in subscriptions)
|
||||
{
|
||||
if (subscription.Status is StripeSubscriptionStatus.Canceled or StripeSubscriptionStatus.IncompleteExpired)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);
|
||||
|
||||
if (organizationId.HasValue || userId.HasValue || providerId.HasValue)
|
||||
{
|
||||
return (organizationId, userId, providerId);
|
||||
}
|
||||
}
|
||||
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
public bool IsSponsoredSubscription(Subscription subscription) =>
|
||||
StaticStore.SponsoredPlans
|
||||
.Any(p => subscription.Items
|
||||
.Any(i => i.Plan.Id == p.StripePlanId));
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Stripe Charge object to a Bitwarden Transaction object.
|
||||
/// </summary>
|
||||
/// <param name="charge"></param>
|
||||
/// <param name="organizationId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// /// <param name="providerId"></param>
|
||||
/// <returns></returns>
|
||||
public Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId)
|
||||
{
|
||||
var transaction = new Transaction
|
||||
{
|
||||
Amount = charge.Amount / 100M,
|
||||
CreationDate = charge.Created,
|
||||
OrganizationId = organizationId,
|
||||
UserId = userId,
|
||||
ProviderId = providerId,
|
||||
Type = TransactionType.Charge,
|
||||
Gateway = GatewayType.Stripe,
|
||||
GatewayId = charge.Id
|
||||
};
|
||||
|
||||
switch (charge.Source)
|
||||
{
|
||||
case Card card:
|
||||
{
|
||||
transaction.PaymentMethodType = PaymentMethodType.Card;
|
||||
transaction.Details = $"{card.Brand}, *{card.Last4}";
|
||||
break;
|
||||
}
|
||||
case BankAccount bankAccount:
|
||||
{
|
||||
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
|
||||
transaction.Details = $"{bankAccount.BankName}, *{bankAccount.Last4}";
|
||||
break;
|
||||
}
|
||||
case Source { Card: not null } source:
|
||||
{
|
||||
transaction.PaymentMethodType = PaymentMethodType.Card;
|
||||
transaction.Details = $"{source.Card.Brand}, *{source.Card.Last4}";
|
||||
break;
|
||||
}
|
||||
case Source { AchDebit: not null } source:
|
||||
{
|
||||
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
|
||||
transaction.Details = $"{source.AchDebit.BankName}, *{source.AchDebit.Last4}";
|
||||
break;
|
||||
}
|
||||
case Source source:
|
||||
{
|
||||
if (source.AchCreditTransfer == null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var achCreditTransfer = source.AchCreditTransfer;
|
||||
|
||||
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
|
||||
transaction.Details = $"ACH => {achCreditTransfer.BankName}, {achCreditTransfer.AccountNumber}";
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
if (charge.PaymentMethodDetails == null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (charge.PaymentMethodDetails.Card != null)
|
||||
{
|
||||
var card = charge.PaymentMethodDetails.Card;
|
||||
transaction.PaymentMethodType = PaymentMethodType.Card;
|
||||
transaction.Details = $"{card.Brand?.ToUpperInvariant()}, *{card.Last4}";
|
||||
}
|
||||
else if (charge.PaymentMethodDetails.AchDebit != null)
|
||||
{
|
||||
var achDebit = charge.PaymentMethodDetails.AchDebit;
|
||||
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
|
||||
transaction.Details = $"{achDebit.BankName}, *{achDebit.Last4}";
|
||||
}
|
||||
else if (charge.PaymentMethodDetails.AchCreditTransfer != null)
|
||||
{
|
||||
var achCreditTransfer = charge.PaymentMethodDetails.AchCreditTransfer;
|
||||
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
|
||||
transaction.Details = $"ACH => {achCreditTransfer.BankName}, {achCreditTransfer.AccountNumber}";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public async Task<bool> AttemptToPayInvoiceAsync(Invoice invoice, bool attemptToPayWithStripe = false)
|
||||
{
|
||||
var customer = await _stripeFacade.GetCustomer(invoice.CustomerId);
|
||||
|
||||
if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false)
|
||||
{
|
||||
return await AttemptToPayInvoiceWithBraintreeAsync(invoice, customer);
|
||||
}
|
||||
|
||||
if (attemptToPayWithStripe)
|
||||
{
|
||||
return await AttemptToPayInvoiceWithStripeAsync(invoice);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ShouldAttemptToPayInvoice(Invoice invoice) =>
|
||||
invoice is
|
||||
{
|
||||
AmountDue: > 0,
|
||||
Paid: false,
|
||||
CollectionMethod: "charge_automatically",
|
||||
BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice",
|
||||
SubscriptionId: not null
|
||||
};
|
||||
|
||||
private async Task<bool> AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice, Customer customer)
|
||||
{
|
||||
_logger.LogDebug("Attempting to pay invoice with Braintree");
|
||||
if (!customer?.Metadata?.ContainsKey("btCustomerId") ?? true)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attempted to pay invoice with Braintree but btCustomerId wasn't on Stripe customer metadata");
|
||||
return false;
|
||||
}
|
||||
|
||||
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
|
||||
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
|
||||
if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attempted to pay invoice with Braintree but Stripe subscription metadata didn't contain either a organizationId or userId or ");
|
||||
return false;
|
||||
}
|
||||
|
||||
var orgTransaction = organizationId.HasValue;
|
||||
string btObjIdField;
|
||||
Guid btObjId;
|
||||
if (organizationId.HasValue)
|
||||
{
|
||||
btObjIdField = "organization_id";
|
||||
btObjId = organizationId.Value;
|
||||
}
|
||||
else if (userId.HasValue)
|
||||
{
|
||||
btObjIdField = "user_id";
|
||||
btObjId = userId.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
btObjIdField = "provider_id";
|
||||
btObjId = providerId.Value;
|
||||
}
|
||||
var btInvoiceAmount = invoice.AmountDue / 100M;
|
||||
|
||||
var existingTransactions = organizationId.HasValue
|
||||
? await _transactionRepository.GetManyByOrganizationIdAsync(organizationId.Value)
|
||||
: userId.HasValue
|
||||
? await _transactionRepository.GetManyByUserIdAsync(userId.Value)
|
||||
: await _transactionRepository.GetManyByProviderIdAsync(providerId.Value);
|
||||
|
||||
var duplicateTimeSpan = TimeSpan.FromHours(24);
|
||||
var now = DateTime.UtcNow;
|
||||
var duplicateTransaction = existingTransactions?
|
||||
.FirstOrDefault(t => (now - t.CreationDate) < duplicateTimeSpan);
|
||||
if (duplicateTransaction != null)
|
||||
{
|
||||
_logger.LogWarning("There is already a recent PayPal transaction ({0}). " +
|
||||
"Do not charge again to prevent possible duplicate.", duplicateTransaction.GatewayId);
|
||||
return false;
|
||||
}
|
||||
|
||||
Result<Braintree.Transaction> transactionResult;
|
||||
try
|
||||
{
|
||||
transactionResult = await _btGateway.Transaction.SaleAsync(
|
||||
new Braintree.TransactionRequest
|
||||
{
|
||||
Amount = btInvoiceAmount,
|
||||
CustomerId = customer.Metadata["btCustomerId"],
|
||||
Options = new Braintree.TransactionOptionsRequest
|
||||
{
|
||||
SubmitForSettlement = true,
|
||||
PayPal = new Braintree.TransactionOptionsPayPalRequest
|
||||
{
|
||||
CustomField =
|
||||
$"{btObjIdField}:{btObjId},region:{_globalSettings.BaseServiceUri.CloudRegion}"
|
||||
}
|
||||
},
|
||||
CustomFields = new Dictionary<string, string>
|
||||
{
|
||||
[btObjIdField] = btObjId.ToString(),
|
||||
["region"] = _globalSettings.BaseServiceUri.CloudRegion
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (NotFoundException e)
|
||||
{
|
||||
_logger.LogError(e,
|
||||
"Attempted to make a payment with Braintree, but customer did not exist for the given btCustomerId present on the Stripe metadata");
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!transactionResult.IsSuccess())
|
||||
{
|
||||
if (invoice.AttemptCount < 4)
|
||||
{
|
||||
await _mailService.SendPaymentFailedAsync(customer.Email, btInvoiceAmount, true);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _stripeFacade.UpdateInvoice(invoice.Id, new InvoiceUpdateOptions
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["btTransactionId"] = transactionResult.Target.Id,
|
||||
["btPayPalTransactionId"] =
|
||||
transactionResult.Target.PayPalDetails?.AuthorizationId
|
||||
}
|
||||
});
|
||||
await _stripeFacade.PayInvoice(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
await _btGateway.Transaction.RefundAsync(transactionResult.Target.Id);
|
||||
if (e.Message.Contains("Invoice is already paid"))
|
||||
{
|
||||
await _stripeFacade.UpdateInvoice(invoice.Id, new InvoiceUpdateOptions
|
||||
{
|
||||
Metadata = invoice.Metadata
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> AttemptToPayInvoiceWithStripeAsync(Invoice invoice)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _stripeFacade.PayInvoice(invoice.Id);
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
e,
|
||||
"Exception occurred while trying to pay Stripe invoice with Id: {InvoiceId}",
|
||||
invoice.Id);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Core.Services;
|
||||
using Event = Stripe.Event;
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
|
||||
{
|
||||
private readonly IStripeEventService _stripeEventService;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||
|
||||
public SubscriptionDeletedHandler(
|
||||
IStripeEventService stripeEventService,
|
||||
IOrganizationService organizationService,
|
||||
IUserService userService,
|
||||
IStripeEventUtilityService stripeEventUtilityService)
|
||||
{
|
||||
_stripeEventService = stripeEventService;
|
||||
_organizationService = organizationService;
|
||||
_userService = userService;
|
||||
_stripeEventUtilityService = stripeEventUtilityService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="HandledStripeWebhook.SubscriptionDeleted"/> event type from Stripe.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent"></param>
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var subscription = await _stripeEventService.GetSubscription(parsedEvent, true);
|
||||
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
|
||||
var subCanceled = subscription.Status == StripeSubscriptionStatus.Canceled;
|
||||
|
||||
if (!subCanceled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (organizationId.HasValue)
|
||||
{
|
||||
await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
||||
}
|
||||
else if (userId.HasValue)
|
||||
{
|
||||
await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,176 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Stripe;
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
{
|
||||
private readonly IStripeEventService _stripeEventService;
|
||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IStripeFacade _stripeFacade;
|
||||
private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand;
|
||||
private readonly IUserService _userService;
|
||||
|
||||
public SubscriptionUpdatedHandler(
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeEventUtilityService stripeEventUtilityService,
|
||||
IOrganizationService organizationService,
|
||||
IStripeFacade stripeFacade,
|
||||
IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand,
|
||||
IUserService userService)
|
||||
{
|
||||
_stripeEventService = stripeEventService;
|
||||
_stripeEventUtilityService = stripeEventUtilityService;
|
||||
_organizationService = organizationService;
|
||||
_stripeFacade = stripeFacade;
|
||||
_organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand;
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="HandledStripeWebhook.SubscriptionUpdated"/> event type from Stripe.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent"></param>
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts"]);
|
||||
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
|
||||
|
||||
switch (subscription.Status)
|
||||
{
|
||||
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired
|
||||
when organizationId.HasValue:
|
||||
{
|
||||
await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
||||
break;
|
||||
}
|
||||
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired:
|
||||
{
|
||||
if (!userId.HasValue)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (subscription.Status is StripeSubscriptionStatus.Unpaid &&
|
||||
subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore))
|
||||
{
|
||||
await CancelSubscription(subscription.Id);
|
||||
await VoidOpenInvoices(subscription.Id);
|
||||
}
|
||||
|
||||
await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
|
||||
|
||||
break;
|
||||
}
|
||||
case StripeSubscriptionStatus.Active when organizationId.HasValue:
|
||||
{
|
||||
await _organizationService.EnableAsync(organizationId.Value);
|
||||
break;
|
||||
}
|
||||
case StripeSubscriptionStatus.Active:
|
||||
{
|
||||
if (userId.HasValue)
|
||||
{
|
||||
await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (organizationId.HasValue)
|
||||
{
|
||||
await _organizationService.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
||||
if (_stripeEventUtilityService.IsSponsoredSubscription(subscription))
|
||||
{
|
||||
await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
||||
}
|
||||
|
||||
await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription);
|
||||
}
|
||||
else if (userId.HasValue)
|
||||
{
|
||||
await _userService.UpdatePremiumExpirationAsync(userId.Value, subscription.CurrentPeriodEnd);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CancelSubscription(string subscriptionId) =>
|
||||
await _stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions());
|
||||
|
||||
private async Task VoidOpenInvoices(string subscriptionId)
|
||||
{
|
||||
var options = new InvoiceListOptions
|
||||
{
|
||||
Status = StripeInvoiceStatus.Open,
|
||||
Subscription = subscriptionId
|
||||
};
|
||||
var invoices = await _stripeFacade.ListInvoices(options);
|
||||
foreach (var invoice in invoices)
|
||||
{
|
||||
await _stripeFacade.VoidInvoice(invoice.Id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the Password Manager coupon if the organization is removing the Secrets Manager trial.
|
||||
/// Only applies to organizations that have a subscription from the Secrets Manager trial.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent"></param>
|
||||
/// <param name="subscription"></param>
|
||||
private async Task RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(Event parsedEvent,
|
||||
Subscription subscription)
|
||||
{
|
||||
if (parsedEvent.Data.PreviousAttributes?.items is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var previousSubscription = parsedEvent.Data
|
||||
.PreviousAttributes
|
||||
.ToObject<Subscription>() as Subscription;
|
||||
|
||||
// This being false doesn't necessarily mean that the organization doesn't subscribe to Secrets Manager.
|
||||
// If there are changes to any subscription item, Stripe sends every item in the subscription, both
|
||||
// changed and unchanged.
|
||||
var previousSubscriptionHasSecretsManager = previousSubscription?.Items is not null &&
|
||||
previousSubscription.Items.Any(previousItem =>
|
||||
StaticStore.Plans.Any(p =>
|
||||
p.SecretsManager is not null &&
|
||||
p.SecretsManager.StripeSeatPlanId ==
|
||||
previousItem.Plan.Id));
|
||||
|
||||
var currentSubscriptionHasSecretsManager = subscription.Items.Any(i =>
|
||||
StaticStore.Plans.Any(p =>
|
||||
p.SecretsManager is not null &&
|
||||
p.SecretsManager.StripeSeatPlanId == i.Plan.Id));
|
||||
|
||||
if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var customerHasSecretsManagerTrial = subscription.Customer
|
||||
?.Discount
|
||||
?.Coupon
|
||||
?.Id == "sm-standalone";
|
||||
|
||||
var subscriptionHasSecretsManagerTrial = subscription.Discount
|
||||
?.Coupon
|
||||
?.Id == "sm-standalone";
|
||||
|
||||
if (customerHasSecretsManagerTrial)
|
||||
{
|
||||
await _stripeFacade.DeleteCustomerDiscount(subscription.CustomerId);
|
||||
}
|
||||
|
||||
if (subscriptionHasSecretsManagerTrial)
|
||||
{
|
||||
await _stripeFacade.DeleteSubscriptionDiscount(subscription.Id);
|
||||
}
|
||||
}
|
||||
}
|
215
src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs
Normal file
215
src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs
Normal file
@ -0,0 +1,215 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Stripe;
|
||||
using Event = Stripe.Event;
|
||||
using TaxRate = Bit.Core.Entities.TaxRate;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class UpcomingInvoiceHandler : IUpcomingInvoiceHandler
|
||||
{
|
||||
private readonly ILogger<StripeEventProcessor> _logger;
|
||||
private readonly IStripeEventService _stripeEventService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IStripeFacade _stripeFacade;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IValidateSponsorshipCommand _validateSponsorshipCommand;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||
private readonly ITaxRateRepository _taxRateRepository;
|
||||
|
||||
public UpcomingInvoiceHandler(
|
||||
ILogger<StripeEventProcessor> logger,
|
||||
IStripeEventService stripeEventService,
|
||||
IUserService userService,
|
||||
IStripeFacade stripeFacade,
|
||||
IFeatureService featureService,
|
||||
IMailService mailService,
|
||||
IProviderRepository providerRepository,
|
||||
IValidateSponsorshipCommand validateSponsorshipCommand,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IStripeEventUtilityService stripeEventUtilityService,
|
||||
ITaxRateRepository taxRateRepository)
|
||||
{
|
||||
_logger = logger;
|
||||
_stripeEventService = stripeEventService;
|
||||
_userService = userService;
|
||||
_stripeFacade = stripeFacade;
|
||||
_featureService = featureService;
|
||||
_mailService = mailService;
|
||||
_providerRepository = providerRepository;
|
||||
_validateSponsorshipCommand = validateSponsorshipCommand;
|
||||
_organizationRepository = organizationRepository;
|
||||
_stripeEventUtilityService = stripeEventUtilityService;
|
||||
_taxRateRepository = taxRateRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="HandledStripeWebhook.UpcomingInvoice"/> event type from Stripe.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent"></param>
|
||||
/// <exception cref="Exception"></exception>
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent);
|
||||
if (string.IsNullOrEmpty(invoice.SubscriptionId))
|
||||
{
|
||||
_logger.LogWarning("Received 'invoice.upcoming' Event with ID '{eventId}' that did not include a Subscription ID", parsedEvent.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
|
||||
|
||||
if (subscription == null)
|
||||
{
|
||||
throw new Exception(
|
||||
$"Received null Subscription from Stripe for ID '{invoice.SubscriptionId}' while processing Event with ID '{parsedEvent.Id}'");
|
||||
}
|
||||
|
||||
var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax);
|
||||
if (pm5766AutomaticTaxIsEnabled)
|
||||
{
|
||||
var customerGetOptions = new CustomerGetOptions();
|
||||
customerGetOptions.AddExpand("tax");
|
||||
var customer = await _stripeFacade.GetCustomer(subscription.CustomerId, customerGetOptions);
|
||||
if (!subscription.AutomaticTax.Enabled &&
|
||||
customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported)
|
||||
{
|
||||
subscription = await _stripeFacade.UpdateSubscription(subscription.Id,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
DefaultTaxRates = [],
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var updatedSubscription = pm5766AutomaticTaxIsEnabled
|
||||
? subscription
|
||||
: await VerifyCorrectTaxRateForChargeAsync(invoice, subscription);
|
||||
|
||||
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(updatedSubscription.Metadata);
|
||||
|
||||
var invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList();
|
||||
|
||||
if (organizationId.HasValue)
|
||||
{
|
||||
if (_stripeEventUtilityService.IsSponsoredSubscription(updatedSubscription))
|
||||
{
|
||||
await _validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
|
||||
|
||||
if (organization == null || !OrgPlanForInvoiceNotifications(organization))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SendEmails(new List<string> { organization.BillingEmail });
|
||||
|
||||
/*
|
||||
* TODO: https://bitwarden.atlassian.net/browse/PM-4862
|
||||
* Disabling this as part of a hot fix. It needs to check whether the organization
|
||||
* belongs to a Reseller provider and only send an email to the organization owners if it does.
|
||||
* It also requires a new email template as the current one contains too much billing information.
|
||||
*/
|
||||
|
||||
// var ownerEmails = await _organizationRepository.GetOwnerEmailAddressesById(organization.Id);
|
||||
|
||||
// await SendEmails(ownerEmails);
|
||||
}
|
||||
else if (userId.HasValue)
|
||||
{
|
||||
var user = await _userService.GetUserByIdAsync(userId.Value);
|
||||
|
||||
if (user?.Premium == true)
|
||||
{
|
||||
await SendEmails(new List<string> { user.Email });
|
||||
}
|
||||
}
|
||||
else if (providerId.HasValue)
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Received invoice.Upcoming webhook ({EventID}) for Provider ({ProviderID}) that does not exist",
|
||||
parsedEvent.Id,
|
||||
providerId.Value);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await SendEmails(new List<string> { provider.BillingEmail });
|
||||
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
/*
|
||||
* Sends emails to the given email addresses.
|
||||
*/
|
||||
async Task SendEmails(IEnumerable<string> emails)
|
||||
{
|
||||
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
||||
|
||||
if (invoice.NextPaymentAttempt.HasValue)
|
||||
{
|
||||
await _mailService.SendInvoiceUpcoming(
|
||||
validEmails,
|
||||
invoice.AmountDue / 100M,
|
||||
invoice.NextPaymentAttempt.Value,
|
||||
invoiceLineItemDescriptions,
|
||||
true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Stripe.Subscription> VerifyCorrectTaxRateForChargeAsync(Invoice invoice, Stripe.Subscription subscription)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.Country) ||
|
||||
string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.PostalCode))
|
||||
{
|
||||
return subscription;
|
||||
}
|
||||
|
||||
var localBitwardenTaxRates = await _taxRateRepository.GetByLocationAsync(
|
||||
new TaxRate()
|
||||
{
|
||||
Country = invoice.CustomerAddress.Country,
|
||||
PostalCode = invoice.CustomerAddress.PostalCode
|
||||
}
|
||||
);
|
||||
|
||||
if (!localBitwardenTaxRates.Any())
|
||||
{
|
||||
return subscription;
|
||||
}
|
||||
|
||||
var stripeTaxRate = await _stripeFacade.GetTaxRate(localBitwardenTaxRates.First().Id);
|
||||
if (stripeTaxRate == null || subscription.DefaultTaxRates.Any(x => x == stripeTaxRate))
|
||||
{
|
||||
return subscription;
|
||||
}
|
||||
|
||||
subscription.DefaultTaxRates = [stripeTaxRate];
|
||||
|
||||
var subscriptionOptions = new SubscriptionUpdateOptions { DefaultTaxRates = [stripeTaxRate.Id] };
|
||||
subscription = await _stripeFacade.UpdateSubscription(subscription.Id, subscriptionOptions);
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
private static bool OrgPlanForInvoiceNotifications(Organization org) => StaticStore.GetPlan(org.PlanType).IsAnnual;
|
||||
}
|
@ -52,6 +52,21 @@ public class Startup
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
//Handlers
|
||||
services.AddScoped<IStripeEventUtilityService, StripeEventUtilityService>();
|
||||
services.AddScoped<ISubscriptionDeletedHandler, SubscriptionDeletedHandler>();
|
||||
services.AddScoped<ISubscriptionUpdatedHandler, SubscriptionUpdatedHandler>();
|
||||
services.AddScoped<IUpcomingInvoiceHandler, UpcomingInvoiceHandler>();
|
||||
services.AddScoped<IChargeSucceededHandler, ChargeSucceededHandler>();
|
||||
services.AddScoped<IChargeRefundedHandler, ChargeRefundedHandler>();
|
||||
services.AddScoped<ICustomerUpdatedHandler, CustomerUpdatedHandler>();
|
||||
services.AddScoped<IInvoiceCreatedHandler, InvoiceCreatedHandler>();
|
||||
services.AddScoped<IPaymentFailedHandler, PaymentFailedHandler>();
|
||||
services.AddScoped<IPaymentMethodAttachedHandler, PaymentMethodAttachedHandler>();
|
||||
services.AddScoped<IPaymentSucceededHandler, PaymentSucceededHandler>();
|
||||
services.AddScoped<IInvoiceFinalizedHandler, InvoiceFinalizedHandler>();
|
||||
services.AddScoped<IStripeEventProcessor, StripeEventProcessor>();
|
||||
|
||||
// Identity
|
||||
services.AddCustomIdentityServices(globalSettings);
|
||||
//services.AddPasswordlessIdentityServices<ReadOnlyDatabaseIdentityUserStore>(globalSettings);
|
||||
|
@ -3,6 +3,8 @@ using Bit.Core.Entities;
|
||||
using Bit.Core.Models;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Entities;
|
||||
|
||||
public class Group : ITableObject<Guid>, IExternal
|
||||
@ -10,10 +12,10 @@ public class Group : ITableObject<Guid>, IExternal
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
[MaxLength(100)]
|
||||
public string Name { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
public bool AccessAll { get; set; }
|
||||
[MaxLength(300)]
|
||||
public string ExternalId { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
namespace Bit.Core.AdminConsole.Entities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class GroupUser
|
||||
{
|
||||
public Guid GroupId { get; set; }
|
||||
|
@ -10,39 +10,41 @@ using Bit.Core.Models.Business;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Entities;
|
||||
|
||||
public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable, IReferenceable
|
||||
{
|
||||
private Dictionary<TwoFactorProviderType, TwoFactorProvider> _twoFactorProviders;
|
||||
private Dictionary<TwoFactorProviderType, TwoFactorProvider>? _twoFactorProviders;
|
||||
|
||||
public Guid Id { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string Identifier { get; set; }
|
||||
public string? Identifier { get; set; }
|
||||
/// <summary>
|
||||
/// This value is HTML encoded. For display purposes use the method DisplayName() instead.
|
||||
/// </summary>
|
||||
[MaxLength(50)]
|
||||
public string Name { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
/// <summary>
|
||||
/// This value is HTML encoded. For display purposes use the method DisplayBusinessName() instead.
|
||||
/// </summary>
|
||||
[MaxLength(50)]
|
||||
public string BusinessName { get; set; }
|
||||
public string? BusinessName { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string BusinessAddress1 { get; set; }
|
||||
public string? BusinessAddress1 { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string BusinessAddress2 { get; set; }
|
||||
public string? BusinessAddress2 { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string BusinessAddress3 { get; set; }
|
||||
public string? BusinessAddress3 { get; set; }
|
||||
[MaxLength(2)]
|
||||
public string BusinessCountry { get; set; }
|
||||
public string? BusinessCountry { get; set; }
|
||||
[MaxLength(30)]
|
||||
public string BusinessTaxNumber { get; set; }
|
||||
public string? BusinessTaxNumber { get; set; }
|
||||
[MaxLength(256)]
|
||||
public string BillingEmail { get; set; }
|
||||
public string BillingEmail { get; set; } = null!;
|
||||
[MaxLength(50)]
|
||||
public string Plan { get; set; }
|
||||
public string Plan { get; set; } = null!;
|
||||
public PlanType PlanType { get; set; }
|
||||
public int? Seats { get; set; }
|
||||
public short? MaxCollections { get; set; }
|
||||
@ -65,16 +67,16 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
|
||||
public short? MaxStorageGb { get; set; }
|
||||
public GatewayType? Gateway { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string GatewayCustomerId { get; set; }
|
||||
public string? GatewayCustomerId { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string GatewaySubscriptionId { get; set; }
|
||||
public string ReferenceData { get; set; }
|
||||
public string? GatewaySubscriptionId { get; set; }
|
||||
public string? ReferenceData { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
[MaxLength(100)]
|
||||
public string LicenseKey { get; set; }
|
||||
public string PublicKey { get; set; }
|
||||
public string PrivateKey { get; set; }
|
||||
public string TwoFactorProviders { get; set; }
|
||||
public string? LicenseKey { get; set; }
|
||||
public string? PublicKey { get; set; }
|
||||
public string? PrivateKey { get; set; }
|
||||
public string? TwoFactorProviders { get; set; }
|
||||
public DateTime? ExpirationDate { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||
@ -123,22 +125,22 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
|
||||
/// <summary>
|
||||
/// Returns the business name of the organization, HTML decoded ready for display.
|
||||
/// </summary>
|
||||
public string DisplayBusinessName()
|
||||
public string? DisplayBusinessName()
|
||||
{
|
||||
return WebUtility.HtmlDecode(BusinessName);
|
||||
}
|
||||
|
||||
public string BillingEmailAddress()
|
||||
public string? BillingEmailAddress()
|
||||
{
|
||||
return BillingEmail?.ToLowerInvariant()?.Trim();
|
||||
}
|
||||
|
||||
public string BillingName()
|
||||
public string? BillingName()
|
||||
{
|
||||
return DisplayBusinessName();
|
||||
}
|
||||
|
||||
public string SubscriberName()
|
||||
public string? SubscriberName()
|
||||
{
|
||||
return DisplayName();
|
||||
}
|
||||
@ -198,7 +200,7 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
|
||||
return maxStorageBytes - Storage.Value;
|
||||
}
|
||||
|
||||
public Dictionary<TwoFactorProviderType, TwoFactorProvider> GetTwoFactorProviders()
|
||||
public Dictionary<TwoFactorProviderType, TwoFactorProvider>? GetTwoFactorProviders()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(TwoFactorProviders))
|
||||
{
|
||||
@ -257,7 +259,7 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
|
||||
return providers.Any(p => (p.Value?.Enabled ?? false) && Use2fa);
|
||||
}
|
||||
|
||||
public TwoFactorProvider GetTwoFactorProvider(TwoFactorProviderType provider)
|
||||
public TwoFactorProvider? GetTwoFactorProvider(TwoFactorProviderType provider)
|
||||
{
|
||||
var providers = GetTwoFactorProviders();
|
||||
if (providers == null || !providers.ContainsKey(provider))
|
||||
|
@ -4,6 +4,8 @@ using Bit.Core.Models;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class OrganizationUser : ITableObject<Guid>, IExternal
|
||||
@ -12,17 +14,17 @@ public class OrganizationUser : ITableObject<Guid>, IExternal
|
||||
public Guid OrganizationId { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
[MaxLength(256)]
|
||||
public string Email { get; set; }
|
||||
public string Key { get; set; }
|
||||
public string ResetPasswordKey { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Key { get; set; }
|
||||
public string? ResetPasswordKey { get; set; }
|
||||
public OrganizationUserStatusType Status { get; set; }
|
||||
public OrganizationUserType Type { get; set; }
|
||||
public bool AccessAll { get; set; }
|
||||
[MaxLength(300)]
|
||||
public string ExternalId { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
|
||||
public string Permissions { get; set; }
|
||||
public string? Permissions { get; set; }
|
||||
public bool AccessSecretsManager { get; set; }
|
||||
|
||||
public void SetNewId()
|
||||
@ -30,7 +32,7 @@ public class OrganizationUser : ITableObject<Guid>, IExternal
|
||||
Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
|
||||
public Permissions GetPermissions()
|
||||
public Permissions? GetPermissions()
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(Permissions) ? null
|
||||
: CoreHelpers.LoadClassFromJsonData<Permissions>(Permissions);
|
||||
|
@ -3,6 +3,8 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Entities;
|
||||
|
||||
public class Policy : ITableObject<Guid>
|
||||
@ -10,7 +12,7 @@ public class Policy : ITableObject<Guid>
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public PolicyType Type { get; set; }
|
||||
public string Data { get; set; }
|
||||
public string? Data { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
|
||||
|
@ -4,6 +4,8 @@ using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Entities.Provider;
|
||||
|
||||
public class Provider : ITableObject<Guid>, ISubscriber
|
||||
@ -12,18 +14,18 @@ public class Provider : ITableObject<Guid>, ISubscriber
|
||||
/// <summary>
|
||||
/// This value is HTML encoded. For display purposes use the method DisplayName() instead.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
public string? Name { get; set; }
|
||||
/// <summary>
|
||||
/// This value is HTML encoded. For display purposes use the method DisplayBusinessName() instead.
|
||||
/// </summary>
|
||||
public string BusinessName { get; set; }
|
||||
public string BusinessAddress1 { get; set; }
|
||||
public string BusinessAddress2 { get; set; }
|
||||
public string BusinessAddress3 { get; set; }
|
||||
public string BusinessCountry { get; set; }
|
||||
public string BusinessTaxNumber { get; set; }
|
||||
public string BillingEmail { get; set; }
|
||||
public string BillingPhone { get; set; }
|
||||
public string? BusinessName { get; set; }
|
||||
public string? BusinessAddress1 { get; set; }
|
||||
public string? BusinessAddress2 { get; set; }
|
||||
public string? BusinessAddress3 { get; set; }
|
||||
public string? BusinessCountry { get; set; }
|
||||
public string? BusinessTaxNumber { get; set; }
|
||||
public string? BillingEmail { get; set; }
|
||||
public string? BillingPhone { get; set; }
|
||||
public ProviderStatusType Status { get; set; }
|
||||
public bool UseEvents { get; set; }
|
||||
public ProviderType Type { get; set; }
|
||||
@ -31,14 +33,14 @@ public class Provider : ITableObject<Guid>, ISubscriber
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
|
||||
public GatewayType? Gateway { get; set; }
|
||||
public string GatewayCustomerId { get; set; }
|
||||
public string GatewaySubscriptionId { get; set; }
|
||||
public string? GatewayCustomerId { get; set; }
|
||||
public string? GatewaySubscriptionId { get; set; }
|
||||
|
||||
public string BillingEmailAddress() => BillingEmail?.ToLowerInvariant().Trim();
|
||||
public string? BillingEmailAddress() => BillingEmail?.ToLowerInvariant().Trim();
|
||||
|
||||
public string BillingName() => DisplayBusinessName();
|
||||
public string? BillingName() => DisplayBusinessName();
|
||||
|
||||
public string SubscriberName() => DisplayName();
|
||||
public string? SubscriberName() => DisplayName();
|
||||
|
||||
public string BraintreeCustomerIdPrefix() => "p";
|
||||
|
||||
@ -65,7 +67,7 @@ public class Provider : ITableObject<Guid>, ISubscriber
|
||||
/// <summary>
|
||||
/// Returns the name of the provider, HTML decoded ready for display.
|
||||
/// </summary>
|
||||
public string DisplayName()
|
||||
public string? DisplayName()
|
||||
{
|
||||
return WebUtility.HtmlDecode(Name);
|
||||
}
|
||||
@ -73,7 +75,7 @@ public class Provider : ITableObject<Guid>, ISubscriber
|
||||
/// <summary>
|
||||
/// Returns the business name of the provider, HTML decoded ready for display.
|
||||
/// </summary>
|
||||
public string DisplayBusinessName()
|
||||
public string? DisplayBusinessName()
|
||||
{
|
||||
return WebUtility.HtmlDecode(BusinessName);
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Entities.Provider;
|
||||
|
||||
public class ProviderOrganization : ITableObject<Guid>
|
||||
@ -8,8 +10,8 @@ public class ProviderOrganization : ITableObject<Guid>
|
||||
public Guid Id { get; set; }
|
||||
public Guid ProviderId { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public string Key { get; set; }
|
||||
public string Settings { get; set; }
|
||||
public string? Key { get; set; }
|
||||
public string? Settings { get; set; }
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
|
||||
|
||||
|
@ -2,6 +2,8 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Entities.Provider;
|
||||
|
||||
public class ProviderUser : ITableObject<Guid>
|
||||
@ -9,11 +11,11 @@ public class ProviderUser : ITableObject<Guid>
|
||||
public Guid Id { get; set; }
|
||||
public Guid ProviderId { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string Key { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Key { get; set; }
|
||||
public ProviderUserStatusType Status { get; set; }
|
||||
public ProviderUserType Type { get; set; }
|
||||
public string Permissions { get; set; }
|
||||
public string? Permissions { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
|
@ -20,6 +20,7 @@ public class ProviderOrganizationOrganizationDetails
|
||||
public DateTime CreationDate { get; set; }
|
||||
public DateTime RevisionDate { get; set; }
|
||||
public int UserCount { get; set; }
|
||||
public int? OccupiedSeats { get; set; }
|
||||
public int? Seats { get; set; }
|
||||
public string Plan { get; set; }
|
||||
public OrganizationStatusType Status { get; set; }
|
||||
|
@ -0,0 +1,58 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
|
||||
public class RegisterFinishRequestModel : IValidatableObject
|
||||
{
|
||||
[StrictEmailAddress, StringLength(256)]
|
||||
public required string Email { get; set; }
|
||||
public string? EmailVerificationToken { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
public required string MasterPasswordHash { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? MasterPasswordHint { get; set; }
|
||||
|
||||
public required string UserSymmetricKey { get; set; }
|
||||
|
||||
public required KeysRequestModel UserAsymmetricKeys { get; set; }
|
||||
|
||||
public required KdfType Kdf { get; set; }
|
||||
public required int KdfIterations { get; set; }
|
||||
public int? KdfMemory { get; set; }
|
||||
public int? KdfParallelism { get; set; }
|
||||
|
||||
public Guid? OrganizationUserId { get; set; }
|
||||
public string? OrgInviteToken { get; set; }
|
||||
|
||||
|
||||
public User ToUser()
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Email = Email,
|
||||
MasterPasswordHint = MasterPasswordHint,
|
||||
Kdf = Kdf,
|
||||
KdfIterations = KdfIterations,
|
||||
KdfMemory = KdfMemory,
|
||||
KdfParallelism = KdfParallelism,
|
||||
Key = UserSymmetricKey,
|
||||
};
|
||||
|
||||
UserAsymmetricKeys.ToUser(user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
return KdfSettingsValidator.Validate(Kdf, KdfIterations, KdfMemory, KdfParallelism);
|
||||
}
|
||||
}
|
@ -39,17 +39,14 @@ public class RegistrationEmailVerificationTokenable : ExpiringTokenable
|
||||
ReceiveMarketingEmails = receiveMarketingEmails;
|
||||
}
|
||||
|
||||
public bool TokenIsValid(string email, string name = default, bool receiveMarketingEmails = default)
|
||||
public bool TokenIsValid(string email)
|
||||
{
|
||||
if (Email == default || email == default)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Note: string.Equals handles nulls without throwing an exception
|
||||
return string.Equals(Name, name, StringComparison.InvariantCultureIgnoreCase) &&
|
||||
Email.Equals(email, StringComparison.InvariantCultureIgnoreCase) &&
|
||||
ReceiveMarketingEmails == receiveMarketingEmails;
|
||||
return Email.Equals(email, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
// Validates deserialized
|
||||
@ -57,4 +54,5 @@ public class RegistrationEmailVerificationTokenable : ExpiringTokenable
|
||||
Identifier == TokenIdentifier
|
||||
&& !string.IsNullOrWhiteSpace(Email);
|
||||
|
||||
|
||||
}
|
||||
|
@ -411,7 +411,7 @@ public class EmergencyAccessService : IEmergencyAccessService
|
||||
throw new BadRequestException("Emergency Access not valid.");
|
||||
}
|
||||
|
||||
var cipher = await _cipherRepository.GetByIdAsync(cipherId, emergencyAccess.GrantorId, UseFlexibleCollections);
|
||||
var cipher = await _cipherRepository.GetByIdAsync(cipherId, emergencyAccess.GrantorId);
|
||||
return await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId);
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,40 @@
|
||||
using Bit.Core.Entities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.Registration;
|
||||
|
||||
public interface IRegisterUserCommand
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new user, sends a welcome email, and raises the signup reference event.
|
||||
/// </summary>
|
||||
/// <param name="user">The <see cref="User"/> to create</param>
|
||||
/// <returns><see cref="IdentityResult"/></returns>
|
||||
public Task<IdentityResult> RegisterUser(User user);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new user with a given master password hash, sends a welcome email (differs based on initiation path),
|
||||
/// and raises the signup reference event. Optionally accepts an org invite token and org user id to associate
|
||||
/// the user with an organization upon registration and login. Both are required if either is provided or validation will fail.
|
||||
/// If the organization has a 2FA required policy enabled, email verification will be enabled for the user.
|
||||
/// </summary>
|
||||
/// <param name="user">The <see cref="User"/> to create</param>
|
||||
/// <param name="masterPasswordHash">The hashed master password the user entered</param>
|
||||
/// <param name="orgInviteToken">The org invite token sent to the user via email</param>
|
||||
/// <param name="orgUserId">The associated org user guid that was created at the time of invite</param>
|
||||
/// <returns><see cref="IdentityResult"/></returns>
|
||||
public Task<IdentityResult> RegisterUserWithOptionalOrgInvite(User user, string masterPasswordHash, string orgInviteToken, Guid? orgUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new user with a given master password hash, sends a welcome email, and raises the signup reference event.
|
||||
/// If a valid email verification token is provided, the user will be created with their email verified.
|
||||
/// An error will be thrown if the token is invalid or expired.
|
||||
/// </summary>
|
||||
/// <param name="user">The <see cref="User"/> to create</param>
|
||||
/// <param name="masterPasswordHash">The hashed master password the user entered</param>
|
||||
/// <param name="emailVerificationToken">The email verification token sent to the user via email</param>
|
||||
/// <returns></returns>
|
||||
public Task<IdentityResult> RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash, string emailVerificationToken);
|
||||
|
||||
}
|
@ -0,0 +1,265 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
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.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Business;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.Registration.Implementations;
|
||||
|
||||
public class RegisterUserCommand : IRegisterUserCommand
|
||||
{
|
||||
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;
|
||||
private readonly IDataProtector _organizationServiceDataProtector;
|
||||
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
private readonly IUserService _userService;
|
||||
private readonly IMailService _mailService;
|
||||
|
||||
public RegisterUserCommand(
|
||||
IGlobalSettings globalSettings,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IReferenceEventService referenceEventService,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> registrationEmailVerificationTokenDataFactory,
|
||||
ICurrentContext currentContext,
|
||||
IUserService userService,
|
||||
IMailService mailService
|
||||
)
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_policyRepository = policyRepository;
|
||||
_referenceEventService = referenceEventService;
|
||||
|
||||
_organizationServiceDataProtector = dataProtectionProvider.CreateProtector(
|
||||
"OrganizationServiceDataProtector");
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
_registrationEmailVerificationTokenDataFactory = registrationEmailVerificationTokenDataFactory;
|
||||
|
||||
_currentContext = currentContext;
|
||||
_userService = userService;
|
||||
_mailService = mailService;
|
||||
|
||||
}
|
||||
|
||||
|
||||
public async Task<IdentityResult> RegisterUser(User user)
|
||||
{
|
||||
var result = await _userService.CreateUserAsync(user);
|
||||
if (result == IdentityResult.Success)
|
||||
{
|
||||
await _mailService.SendWelcomeEmailAsync(user);
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
public async Task<IdentityResult> RegisterUserWithOptionalOrgInvite(User user, string masterPasswordHash,
|
||||
string orgInviteToken, Guid? orgUserId)
|
||||
{
|
||||
ValidateOrgInviteToken(orgInviteToken, orgUserId, user);
|
||||
await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user);
|
||||
|
||||
user.ApiKey = CoreHelpers.SecureRandomString(30);
|
||||
|
||||
if (!string.IsNullOrEmpty(orgInviteToken) && orgUserId.HasValue)
|
||||
{
|
||||
user.EmailVerified = true;
|
||||
}
|
||||
|
||||
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
|
||||
if (result == IdentityResult.Success)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(user.ReferenceData))
|
||||
{
|
||||
var referenceData = JsonConvert.DeserializeObject<Dictionary<string, object>>(user.ReferenceData);
|
||||
if (referenceData.TryGetValue("initiationPath", out var value))
|
||||
{
|
||||
var initiationPath = value.ToString();
|
||||
await SendAppropriateWelcomeEmailAsync(user, initiationPath);
|
||||
if (!string.IsNullOrEmpty(initiationPath))
|
||||
{
|
||||
await _referenceEventService.RaiseEventAsync(
|
||||
new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)
|
||||
{
|
||||
SignupInitiationPath = initiationPath
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, User user)
|
||||
{
|
||||
const string disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator.";
|
||||
|
||||
var orgInviteTokenProvided = !string.IsNullOrWhiteSpace(orgInviteToken);
|
||||
|
||||
if (orgInviteTokenProvided && orgUserId.HasValue)
|
||||
{
|
||||
// We have token data so validate it
|
||||
if (IsOrgInviteTokenValid(orgInviteToken, orgUserId.Value, user.Email))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Token data is invalid
|
||||
|
||||
if (_globalSettings.DisableUserRegistration)
|
||||
{
|
||||
throw new BadRequestException(disabledUserRegistrationExceptionMsg);
|
||||
}
|
||||
|
||||
throw new BadRequestException("Organization invite token is invalid.");
|
||||
}
|
||||
|
||||
// no token data or missing token data
|
||||
|
||||
// Throw if open registration is disabled and there isn't an org invite token or an org user id
|
||||
// as you can't register without them.
|
||||
if (_globalSettings.DisableUserRegistration)
|
||||
{
|
||||
throw new BadRequestException(disabledUserRegistrationExceptionMsg);
|
||||
}
|
||||
|
||||
// Open registration is allowed
|
||||
// if we have an org invite token but no org user id, then throw an exception as we can't validate the token
|
||||
if (orgInviteTokenProvided && !orgUserId.HasValue)
|
||||
{
|
||||
throw new BadRequestException("Organization invite token cannot be validated without an organization user id.");
|
||||
}
|
||||
|
||||
// if we have an org user id but no org invite token, then throw an exception as that isn't a supported flow
|
||||
if (orgUserId.HasValue && string.IsNullOrWhiteSpace(orgInviteToken))
|
||||
{
|
||||
throw new BadRequestException("Organization user id cannot be provided without an organization invite token.");
|
||||
}
|
||||
|
||||
// If both orgInviteToken && orgUserId are missing, then proceed with open registration
|
||||
}
|
||||
|
||||
private bool IsOrgInviteTokenValid(string orgInviteToken, Guid orgUserId, string userEmail)
|
||||
{
|
||||
// TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
|
||||
var newOrgInviteTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
|
||||
_orgUserInviteTokenDataFactory, orgInviteToken, orgUserId, userEmail);
|
||||
|
||||
return newOrgInviteTokenValid || CoreHelpers.UserInviteTokenIsValid(
|
||||
_organizationServiceDataProtector, orgInviteToken, userEmail, orgUserId, _globalSettings);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Handles initializing the user with Email 2FA enabled if they are subject to an enabled 2FA organizational policy.
|
||||
/// </summary>
|
||||
/// <param name="orgUserId">The optional org user id</param>
|
||||
/// <param name="user">The newly created user object which could be modified</param>
|
||||
private async Task SetUserEmail2FaIfOrgPolicyEnabledAsync(Guid? orgUserId, User user)
|
||||
{
|
||||
if (!orgUserId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value);
|
||||
if (orgUser != null)
|
||||
{
|
||||
var twoFactorPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgUser.OrganizationId,
|
||||
PolicyType.TwoFactorAuthentication);
|
||||
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
|
||||
{
|
||||
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
||||
{
|
||||
|
||||
[TwoFactorProviderType.Email] = new TwoFactorProvider
|
||||
{
|
||||
MetaData = new Dictionary<string, object> { ["Email"] = user.Email.ToLowerInvariant() },
|
||||
Enabled = true
|
||||
}
|
||||
});
|
||||
_userService.SetTwoFactorProvider(user, TwoFactorProviderType.Email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath)
|
||||
{
|
||||
var isFromMarketingWebsite = initiationPath.Contains("Secrets Manager trial");
|
||||
|
||||
if (isFromMarketingWebsite)
|
||||
{
|
||||
await _mailService.SendTrialInitiationEmailAsync(user.Email);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _mailService.SendWelcomeEmailAsync(user);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IdentityResult> RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash,
|
||||
string emailVerificationToken)
|
||||
{
|
||||
var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email);
|
||||
|
||||
user.EmailVerified = true;
|
||||
user.Name = tokenable.Name;
|
||||
user.ApiKey = CoreHelpers.SecureRandomString(30); // API key can't be null.
|
||||
|
||||
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
|
||||
if (result == IdentityResult.Success)
|
||||
{
|
||||
await _mailService.SendWelcomeEmailAsync(user);
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)
|
||||
{
|
||||
ReceiveMarketingEmails = tokenable.ReceiveMarketingEmails
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private RegistrationEmailVerificationTokenable ValidateRegistrationEmailVerificationTokenable(string emailVerificationToken, string userEmail)
|
||||
{
|
||||
_registrationEmailVerificationTokenDataFactory.TryUnprotect(emailVerificationToken, out var tokenable);
|
||||
if (tokenable == null || !tokenable.Valid || !tokenable.TokenIsValid(userEmail))
|
||||
{
|
||||
throw new BadRequestException("Invalid email verification token.");
|
||||
}
|
||||
|
||||
return tokenable;
|
||||
}
|
||||
}
|
@ -20,17 +20,21 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _tokenDataFactory;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public SendVerificationEmailForRegistrationCommand(
|
||||
IUserRepository userRepository,
|
||||
GlobalSettings globalSettings,
|
||||
IMailService mailService,
|
||||
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> tokenDataFactory)
|
||||
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> tokenDataFactory,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_globalSettings = globalSettings;
|
||||
_mailService = mailService;
|
||||
_tokenDataFactory = tokenDataFactory;
|
||||
_featureService = featureService;
|
||||
|
||||
}
|
||||
|
||||
public async Task<string?> Run(string email, string? name, bool receiveMarketingEmails)
|
||||
@ -44,15 +48,23 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
|
||||
var user = await _userRepository.GetByEmailAsync(email);
|
||||
var userExists = user != null;
|
||||
|
||||
// Delays enabled by default; flag must be enabled to remove the delays.
|
||||
var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays);
|
||||
|
||||
if (!_globalSettings.EnableEmailVerification)
|
||||
{
|
||||
|
||||
if (userExists)
|
||||
{
|
||||
// Add delay to prevent timing attacks
|
||||
// Note: sub 140 ms feels responsive to users so we are using 130 ms as it should be long enough
|
||||
// to prevent timing attacks but not too long to be noticeable to the user.
|
||||
await Task.Delay(130);
|
||||
|
||||
if (delaysEnabled)
|
||||
{
|
||||
// Add delay to prevent timing attacks
|
||||
// Note: sub 140 ms feels responsive to users so we are using a random value between 100 - 130 ms
|
||||
// as it should be long enough to prevent timing attacks but not too long to be noticeable to the user.
|
||||
await Task.Delay(Random.Shared.Next(100, 130));
|
||||
}
|
||||
|
||||
throw new BadRequestException($"Email {email} is already taken");
|
||||
}
|
||||
|
||||
@ -70,8 +82,11 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
|
||||
await _mailService.SendRegistrationVerificationEmailAsync(email, token);
|
||||
}
|
||||
|
||||
// Add delay to prevent timing attacks
|
||||
await Task.Delay(130);
|
||||
if (delaysEnabled)
|
||||
{
|
||||
// Add random delay between 100ms-130ms to prevent timing attacks
|
||||
await Task.Delay(Random.Shared.Next(100, 130));
|
||||
}
|
||||
// User exists but we will return a 200 regardless of whether the email was sent or not; so return null
|
||||
return null;
|
||||
}
|
||||
|
@ -37,6 +37,7 @@ public static class UserServiceCollectionExtensions
|
||||
private static void AddUserRegistrationCommands(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<ISendVerificationEmailForRegistrationCommand, SendVerificationEmailForRegistrationCommand>();
|
||||
services.AddScoped<IRegisterUserCommand, RegisterUserCommand>();
|
||||
}
|
||||
|
||||
private static void AddWebAuthnLoginCommands(this IServiceCollection services)
|
||||
|
@ -1,16 +1,24 @@
|
||||
using Bit.Core.Entities;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Billing.Entities;
|
||||
|
||||
public class ProviderInvoiceItem : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ProviderId { get; set; }
|
||||
public string InvoiceId { get; set; }
|
||||
public string InvoiceNumber { get; set; }
|
||||
public string ClientName { get; set; }
|
||||
public string PlanName { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string InvoiceId { get; set; } = null!;
|
||||
[MaxLength(50)]
|
||||
public string? InvoiceNumber { get; set; }
|
||||
public Guid? ClientId { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string ClientName { get; set; } = null!;
|
||||
[MaxLength(50)]
|
||||
public string PlanName { get; set; } = null!;
|
||||
public int AssignedSeats { get; set; }
|
||||
public int UsedSeats { get; set; }
|
||||
public decimal Total { get; set; }
|
||||
|
@ -2,6 +2,8 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Billing.Entities;
|
||||
|
||||
public class ProviderPlan : ITableObject<Guid>
|
||||
|
@ -5,5 +5,5 @@ namespace Bit.Core.Billing.Models;
|
||||
public record ConsolidatedBillingSubscriptionDTO(
|
||||
List<ConfiguredProviderPlanDTO> ProviderPlans,
|
||||
Subscription Subscription,
|
||||
DateTime? SuspensionDate,
|
||||
DateTime? UnpaidPeriodEndDate);
|
||||
TaxInformationDTO TaxInformation,
|
||||
SubscriptionSuspensionDTO Suspension);
|
||||
|
6
src/Core/Billing/Models/SubscriptionSuspensionDTO.cs
Normal file
6
src/Core/Billing/Models/SubscriptionSuspensionDTO.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record SubscriptionSuspensionDTO(
|
||||
DateTime SuspensionDate,
|
||||
DateTime UnpaidPeriodEndDate,
|
||||
int GracePeriod);
|
@ -1,6 +1,7 @@
|
||||
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.Models;
|
||||
using Bit.Core.Models.Business;
|
||||
@ -43,6 +44,12 @@ public interface IProviderBillingService
|
||||
Provider provider,
|
||||
Organization organization);
|
||||
|
||||
/// <summary>
|
||||
/// Generate a provider's client invoice report in CSV format for the specified <paramref name="invoiceId"/>. Utilizes the <see cref="ProviderInvoiceItem"/>
|
||||
/// records saved for the <paramref name="invoiceId"/> as part of our webhook processing for the <b>"invoice.created"</b> and <b>"invoice.finalized"</b> Stripe events.
|
||||
/// </summary>
|
||||
/// <param name="invoiceId">The ID of the Stripe <see cref="Stripe.Invoice"/> to generate the report for.</param>
|
||||
/// <returns>The provider's client invoice report as a byte array.</returns>
|
||||
Task<byte[]> GenerateClientInvoiceReport(
|
||||
string invoiceId);
|
||||
|
||||
|
@ -1,4 +1,8 @@
|
||||
namespace Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Services;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing;
|
||||
|
||||
public static class Utilities
|
||||
{
|
||||
@ -8,4 +12,67 @@ public static class Utilities
|
||||
string internalMessage = null,
|
||||
Exception innerException = null) => new("Something went wrong with your request. Please contact support.",
|
||||
internalMessage, innerException);
|
||||
|
||||
public static async Task<SubscriptionSuspensionDTO> GetSuspensionAsync(
|
||||
IStripeAdapter stripeAdapter,
|
||||
Subscription subscription)
|
||||
{
|
||||
if (subscription.Status is not "past_due" && subscription.Status is not "unpaid")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions
|
||||
{
|
||||
Query = $"subscription:'{subscription.Id}' status:'open'"
|
||||
});
|
||||
|
||||
if (openInvoices.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var currentDate = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
|
||||
|
||||
switch (subscription.CollectionMethod)
|
||||
{
|
||||
case "charge_automatically":
|
||||
{
|
||||
var firstOverdueInvoice = openInvoices
|
||||
.Where(invoice => invoice.PeriodEnd < currentDate && invoice.Attempted)
|
||||
.MinBy(invoice => invoice.Created);
|
||||
|
||||
if (firstOverdueInvoice == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const int gracePeriod = 14;
|
||||
|
||||
return new SubscriptionSuspensionDTO(
|
||||
firstOverdueInvoice.Created.AddDays(gracePeriod),
|
||||
firstOverdueInvoice.PeriodEnd,
|
||||
gracePeriod);
|
||||
}
|
||||
case "send_invoice":
|
||||
{
|
||||
var firstOverdueInvoice = openInvoices
|
||||
.Where(invoice => invoice.DueDate < currentDate)
|
||||
.MinBy(invoice => invoice.Created);
|
||||
|
||||
if (firstOverdueInvoice?.DueDate == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const int gracePeriod = 30;
|
||||
|
||||
return new SubscriptionSuspensionDTO(
|
||||
firstOverdueInvoice.DueDate.Value.AddDays(gracePeriod),
|
||||
firstOverdueInvoice.PeriodEnd,
|
||||
gracePeriod);
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -124,6 +124,7 @@ public static class FeatureFlagKeys
|
||||
public const string UnassignedItemsBanner = "unassigned-items-banner";
|
||||
public const string EnableDeleteProvider = "AC-1218-delete-provider";
|
||||
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";
|
||||
@ -132,6 +133,8 @@ public static class FeatureFlagKeys
|
||||
public const string MemberAccessReport = "ac-2059-member-access-report";
|
||||
public const string BlockLegacyUsers = "block-legacy-users";
|
||||
public const string InlineMenuFieldQualification = "inline-menu-field-qualification";
|
||||
public const string TwoFactorComponentRefactor = "two-factor-component-refactor";
|
||||
public const string GroupsComponentRefactor = "groups-component-refactor";
|
||||
|
||||
public static List<string> GetAllKeys()
|
||||
{
|
||||
@ -147,7 +150,8 @@ public static class FeatureFlagKeys
|
||||
return new Dictionary<string, string>()
|
||||
{
|
||||
{ DuoRedirect, "true" },
|
||||
{ UnassignedItemsBanner, "true"}
|
||||
{ UnassignedItemsBanner, "true"},
|
||||
{ FlexibleCollectionsV1, "true" }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -21,8 +21,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.300.108" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.301.22" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.300.110" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.301.24" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.8.3" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
|
||||
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.17.5" />
|
||||
@ -34,14 +34,15 @@
|
||||
<PackageReference Include="Fido2.AspNet" Version="3.0.1" />
|
||||
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
|
||||
<PackageReference Include="MailKit" Version="4.6.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.25" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.31" />
|
||||
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.39.1" />
|
||||
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="6.0.25" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="6.0.31" />
|
||||
<PackageReference Include="Quartz" Version="3.9.0" />
|
||||
<PackageReference Include="SendGrid" Version="9.29.3" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
@ -56,7 +57,7 @@
|
||||
<PackageReference Include="Stripe.net" Version="43.20.0" />
|
||||
<PackageReference Include="Otp.NET" Version="1.3.0" />
|
||||
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="6.0.25" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="6.0.31" />
|
||||
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.5.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@ -1,15 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class Collection : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
[MaxLength(300)]
|
||||
public string ExternalId { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class CollectionCipher
|
||||
{
|
||||
public Guid CollectionId { get; set; }
|
||||
|
@ -1,5 +1,7 @@
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class CollectionGroup
|
||||
{
|
||||
public Guid CollectionId { get; set; }
|
||||
|
@ -1,5 +1,7 @@
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class CollectionUser
|
||||
{
|
||||
public Guid CollectionId { get; set; }
|
||||
|
@ -1,6 +1,8 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class Device : ITableObject<Guid>
|
||||
@ -8,12 +10,12 @@ public class Device : ITableObject<Guid>
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string Name { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
public Enums.DeviceType Type { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string Identifier { get; set; }
|
||||
public string Identifier { get; set; } = null!;
|
||||
[MaxLength(255)]
|
||||
public string PushToken { get; set; }
|
||||
public string? PushToken { get; set; }
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
|
||||
|
||||
@ -21,20 +23,20 @@ public class Device : ITableObject<Guid>
|
||||
/// Intended to be the users symmetric key that is encrypted in some form, the current way to encrypt this is with
|
||||
/// the devices public key.
|
||||
/// </summary>
|
||||
public string EncryptedUserKey { get; set; }
|
||||
public string? EncryptedUserKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Intended to be the public key that was generated for a device upon trust and encrypted. Currenly encrypted using
|
||||
/// a users symmetric key so that when trusted and unlocked a user can decrypt the public key for all their devices.
|
||||
/// This enabled a user to rotate the keys for all of their devices.
|
||||
/// </summary>
|
||||
public string EncryptedPublicKey { get; set; }
|
||||
public string? EncryptedPublicKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Intended to be the private key that was generated for a device upon trust and encrypted. Currenly encrypted with
|
||||
/// the devices key, that upon successful login a user can decrypt this value and therefor decrypt their vault.
|
||||
/// </summary>
|
||||
public string EncryptedPrivateKey { get; set; }
|
||||
public string? EncryptedPrivateKey { get; set; }
|
||||
|
||||
|
||||
public void SetNewId()
|
||||
|
@ -3,6 +3,8 @@ using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class Event : ITableObject<Guid>, IEvent
|
||||
@ -49,10 +51,10 @@ public class Event : ITableObject<Guid>, IEvent
|
||||
public Guid? ProviderOrganizationId { get; set; }
|
||||
public DeviceType? DeviceType { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string IpAddress { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public Guid? ActingUserId { get; set; }
|
||||
public EventSystemUser? SystemUser { get; set; }
|
||||
public string DomainName { get; set; }
|
||||
public string? DomainName { get; set; }
|
||||
public Guid? SecretId { get; set; }
|
||||
public Guid? ServiceAccountId { get; set; }
|
||||
|
||||
|
@ -1,13 +1,15 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Vault.Entities;
|
||||
|
||||
public class Folder : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public interface IRevisable
|
||||
{
|
||||
DateTime CreationDate { get; }
|
||||
|
@ -1,5 +1,7 @@
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public interface IStorable
|
||||
{
|
||||
long? Storage { get; set; }
|
||||
|
@ -1,4 +1,6 @@
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public interface IStorableSubscriber : IStorable, ISubscriber
|
||||
{ }
|
||||
|
@ -1,16 +1,18 @@
|
||||
using Bit.Core.Enums;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public interface ISubscriber
|
||||
{
|
||||
Guid Id { get; }
|
||||
GatewayType? Gateway { get; set; }
|
||||
string GatewayCustomerId { get; set; }
|
||||
string GatewaySubscriptionId { get; set; }
|
||||
string BillingEmailAddress();
|
||||
string BillingName();
|
||||
string SubscriberName();
|
||||
string? GatewayCustomerId { get; set; }
|
||||
string? GatewaySubscriptionId { get; set; }
|
||||
string? BillingEmailAddress();
|
||||
string? BillingName();
|
||||
string? SubscriberName();
|
||||
string BraintreeCustomerIdPrefix();
|
||||
string BraintreeIdField();
|
||||
string BraintreeCloudRegionField();
|
||||
|
@ -1,5 +1,7 @@
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public interface ITableObject<T> where T : IEquatable<T>
|
||||
{
|
||||
T Id { get; set; }
|
||||
|
@ -1,15 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class Installation : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
[MaxLength(256)]
|
||||
public string Email { get; set; }
|
||||
public string Email { get; set; } = null!;
|
||||
[MaxLength(150)]
|
||||
public string Key { get; set; }
|
||||
public string Key { get; set; } = null!;
|
||||
public bool Enabled { get; set; }
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
|
||||
|
@ -2,6 +2,8 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class OrganizationApiKey : ITableObject<Guid>
|
||||
@ -10,7 +12,7 @@ public class OrganizationApiKey : ITableObject<Guid>
|
||||
public Guid OrganizationId { get; set; }
|
||||
public OrganizationApiKeyType Type { get; set; }
|
||||
[MaxLength(30)]
|
||||
public string ApiKey { get; set; }
|
||||
public string ApiKey { get; set; } = null!;
|
||||
public DateTime RevisionDate { get; set; }
|
||||
|
||||
public void SetNewId()
|
||||
|
@ -1,13 +1,17 @@
|
||||
using System.Text.Json;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.OrganizationConnectionConfigs;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class OrganizationConnection<T> : OrganizationConnection where T : IConnectionConfig
|
||||
{
|
||||
public new T Config
|
||||
[DisallowNull]
|
||||
public new T? Config
|
||||
{
|
||||
get => base.GetConfig<T>();
|
||||
set => base.SetConfig(value);
|
||||
@ -20,17 +24,22 @@ public class OrganizationConnection : ITableObject<Guid>
|
||||
public OrganizationConnectionType Type { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
public string Config { get; set; }
|
||||
public string? Config { get; set; }
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
|
||||
public T GetConfig<T>() where T : IConnectionConfig
|
||||
public T? GetConfig<T>() where T : IConnectionConfig
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Config is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(Config);
|
||||
}
|
||||
catch (JsonException)
|
||||
|
@ -1,15 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class OrganizationDomain : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public string Txt { get; set; }
|
||||
public string Txt { get; set; } = null!;
|
||||
[MaxLength(255)]
|
||||
public string DomainName { get; set; }
|
||||
public string DomainName { get; set; } = null!;
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? VerifiedDate { get; private set; }
|
||||
public DateTime NextRunDate { get; private set; }
|
||||
|
@ -2,6 +2,8 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class OrganizationSponsorship : ITableObject<Guid>
|
||||
@ -11,9 +13,9 @@ public class OrganizationSponsorship : ITableObject<Guid>
|
||||
public Guid SponsoringOrganizationUserId { get; set; }
|
||||
public Guid? SponsoredOrganizationId { get; set; }
|
||||
[MaxLength(256)]
|
||||
public string FriendlyName { get; set; }
|
||||
public string? FriendlyName { get; set; }
|
||||
[MaxLength(256)]
|
||||
public string OfferedToEmail { get; set; }
|
||||
public string? OfferedToEmail { get; set; }
|
||||
public PlanSponsorshipType? PlanSponsorshipType { get; set; }
|
||||
public DateTime? LastSyncDate { get; set; }
|
||||
public DateTime? ValidUntil { get; set; }
|
||||
|
@ -1,9 +1,11 @@
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// This class is not used. It is implemented to make the Identity provider happy.
|
||||
/// </summary>
|
||||
public class Role
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
}
|
||||
|
@ -1,17 +1,19 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class TaxRate : ITableObject<string>
|
||||
{
|
||||
[MaxLength(40)]
|
||||
public string Id { get; set; }
|
||||
public string Id { get; set; } = null!;
|
||||
[MaxLength(50)]
|
||||
public string Country { get; set; }
|
||||
public string Country { get; set; } = null!;
|
||||
[MaxLength(2)]
|
||||
public string State { get; set; }
|
||||
public string? State { get; set; }
|
||||
[MaxLength(10)]
|
||||
public string PostalCode { get; set; }
|
||||
public string PostalCode { get; set; } = null!;
|
||||
public decimal Rate { get; set; }
|
||||
public bool Active { get; set; }
|
||||
|
||||
|
@ -2,6 +2,8 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class Transaction : ITableObject<Guid>
|
||||
@ -14,11 +16,11 @@ public class Transaction : ITableObject<Guid>
|
||||
public bool? Refunded { get; set; }
|
||||
public decimal? RefundedAmount { get; set; }
|
||||
[MaxLength(100)]
|
||||
public string Details { get; set; }
|
||||
public string? Details { get; set; }
|
||||
public PaymentMethodType? PaymentMethodType { get; set; }
|
||||
public GatewayType? Gateway { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string GatewayId { get; set; }
|
||||
public string? GatewayId { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public Guid? ProviderId { get; set; }
|
||||
|
||||
|
@ -7,37 +7,39 @@ using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser, IReferenceable
|
||||
{
|
||||
private Dictionary<TwoFactorProviderType, TwoFactorProvider> _twoFactorProviders;
|
||||
private Dictionary<TwoFactorProviderType, TwoFactorProvider>? _twoFactorProviders;
|
||||
|
||||
public Guid Id { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string Name { get; set; }
|
||||
public string? Name { get; set; }
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string Email { get; set; }
|
||||
public string Email { get; set; } = null!;
|
||||
public bool EmailVerified { get; set; }
|
||||
[MaxLength(300)]
|
||||
public string MasterPassword { get; set; }
|
||||
public string? MasterPassword { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string MasterPasswordHint { get; set; }
|
||||
public string? MasterPasswordHint { get; set; }
|
||||
[MaxLength(10)]
|
||||
public string Culture { get; set; } = "en-US";
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public string SecurityStamp { get; set; }
|
||||
public string TwoFactorProviders { get; set; }
|
||||
public string SecurityStamp { get; set; } = null!;
|
||||
public string? TwoFactorProviders { get; set; }
|
||||
[MaxLength(32)]
|
||||
public string TwoFactorRecoveryCode { get; set; }
|
||||
public string EquivalentDomains { get; set; }
|
||||
public string ExcludedGlobalEquivalentDomains { get; set; }
|
||||
public string? TwoFactorRecoveryCode { get; set; }
|
||||
public string? EquivalentDomains { get; set; }
|
||||
public string? ExcludedGlobalEquivalentDomains { get; set; }
|
||||
public DateTime AccountRevisionDate { get; set; } = DateTime.UtcNow;
|
||||
public string Key { get; set; }
|
||||
public string PublicKey { get; set; }
|
||||
public string PrivateKey { get; set; }
|
||||
public string? Key { get; set; }
|
||||
public string? PublicKey { get; set; }
|
||||
public string? PrivateKey { get; set; }
|
||||
public bool Premium { get; set; }
|
||||
public DateTime? PremiumExpirationDate { get; set; }
|
||||
public DateTime? RenewalReminderDate { get; set; }
|
||||
@ -45,15 +47,15 @@ public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFac
|
||||
public short? MaxStorageGb { get; set; }
|
||||
public GatewayType? Gateway { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string GatewayCustomerId { get; set; }
|
||||
public string? GatewayCustomerId { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string GatewaySubscriptionId { get; set; }
|
||||
public string ReferenceData { get; set; }
|
||||
public string? GatewaySubscriptionId { get; set; }
|
||||
public string? ReferenceData { get; set; }
|
||||
[MaxLength(100)]
|
||||
public string LicenseKey { get; set; }
|
||||
public string? LicenseKey { get; set; }
|
||||
[Required]
|
||||
[MaxLength(30)]
|
||||
public string ApiKey { get; set; }
|
||||
public string ApiKey { get; set; } = null!;
|
||||
public KdfType Kdf { get; set; } = KdfType.PBKDF2_SHA256;
|
||||
public int KdfIterations { get; set; } = AuthConstants.PBKDF2_ITERATIONS.Default;
|
||||
public int? KdfMemory { get; set; }
|
||||
@ -65,7 +67,7 @@ public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFac
|
||||
public int FailedLoginCount { get; set; }
|
||||
public DateTime? LastFailedLoginDate { get; set; }
|
||||
[MaxLength(7)]
|
||||
public string AvatarColor { get; set; }
|
||||
public string? AvatarColor { get; set; }
|
||||
public DateTime? LastPasswordChangeDate { get; set; }
|
||||
public DateTime? LastKdfChangeDate { get; set; }
|
||||
public DateTime? LastKeyRotationDate { get; set; }
|
||||
@ -76,12 +78,12 @@ public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFac
|
||||
Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
|
||||
public string BillingEmailAddress()
|
||||
public string? BillingEmailAddress()
|
||||
{
|
||||
return Email?.ToLowerInvariant()?.Trim();
|
||||
}
|
||||
|
||||
public string BillingName()
|
||||
public string? BillingName()
|
||||
{
|
||||
return Name;
|
||||
}
|
||||
@ -125,7 +127,7 @@ public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFac
|
||||
|
||||
public bool IsExpired() => PremiumExpirationDate.HasValue && PremiumExpirationDate.Value <= DateTime.UtcNow;
|
||||
|
||||
public Dictionary<TwoFactorProviderType, TwoFactorProvider> GetTwoFactorProviders()
|
||||
public Dictionary<TwoFactorProviderType, TwoFactorProvider>? GetTwoFactorProviders()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(TwoFactorProviders))
|
||||
{
|
||||
@ -178,7 +180,7 @@ public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFac
|
||||
SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>());
|
||||
}
|
||||
|
||||
public TwoFactorProvider GetTwoFactorProvider(TwoFactorProviderType provider)
|
||||
public TwoFactorProvider? GetTwoFactorProvider(TwoFactorProviderType provider)
|
||||
{
|
||||
var providers = GetTwoFactorProviders();
|
||||
if (providers == null || !providers.ContainsKey(provider))
|
||||
|
@ -4,13 +4,13 @@ namespace Bit.Core.Repositories;
|
||||
|
||||
public interface ICollectionCipherRepository
|
||||
{
|
||||
Task<ICollection<CollectionCipher>> GetManyByUserIdAsync(Guid userId, bool useFlexibleCollections);
|
||||
Task<ICollection<CollectionCipher>> GetManyByUserIdAsync(Guid userId);
|
||||
Task<ICollection<CollectionCipher>> GetManyByOrganizationIdAsync(Guid organizationId);
|
||||
Task<ICollection<CollectionCipher>> GetManyByUserIdCipherIdAsync(Guid userId, Guid cipherId, bool useFlexibleCollections);
|
||||
Task UpdateCollectionsAsync(Guid cipherId, Guid userId, IEnumerable<Guid> collectionIds, bool useFlexibleCollections);
|
||||
Task<ICollection<CollectionCipher>> GetManyByUserIdCipherIdAsync(Guid userId, Guid cipherId);
|
||||
Task UpdateCollectionsAsync(Guid cipherId, Guid userId, IEnumerable<Guid> collectionIds);
|
||||
Task UpdateCollectionsForAdminAsync(Guid cipherId, Guid organizationId, IEnumerable<Guid> collectionIds);
|
||||
Task UpdateCollectionsForCiphersAsync(IEnumerable<Guid> cipherIds, Guid userId, Guid organizationId,
|
||||
IEnumerable<Guid> collectionIds, bool useFlexibleCollections);
|
||||
IEnumerable<Guid> collectionIds);
|
||||
|
||||
/// <summary>
|
||||
/// Add the specified collections to the specified ciphers. If a cipher already belongs to a requested collection,
|
||||
|
@ -12,13 +12,6 @@ public interface ICollectionRepository : IRepository<Collection, Guid>
|
||||
/// </summary>
|
||||
Task<Tuple<Collection, CollectionAccessDetails>> GetByIdWithAccessAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a collection with permission details for the provided userId and fetches group/user associations for
|
||||
/// the collection.
|
||||
/// If the user does not have a relationship with the collection, nothing is returned.
|
||||
/// </summary>
|
||||
Task<Tuple<CollectionDetails, CollectionAccessDetails>> GetByIdWithAccessAsync(Guid id, Guid userId, bool useFlexibleCollections);
|
||||
|
||||
/// <summary>
|
||||
/// Return all collections that belong to the organization. Does not include any permission details or group/user
|
||||
/// access relationships.
|
||||
@ -30,18 +23,6 @@ public interface ICollectionRepository : IRepository<Collection, Guid>
|
||||
/// </summary>
|
||||
Task<ICollection<Tuple<Collection, CollectionAccessDetails>>> GetManyByOrganizationIdWithAccessAsync(Guid organizationId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns collections that both, belong to the organization AND have an access relationship with the provided user.
|
||||
/// Includes permission details for the provided user and group/user access relationships for each collection.
|
||||
/// </summary>
|
||||
Task<ICollection<Tuple<CollectionDetails, CollectionAccessDetails>>> GetManyByUserIdWithAccessAsync(Guid userId, Guid organizationId, bool useFlexibleCollections);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a collection with permission details for the provided userId. Does not include group/user access
|
||||
/// relationships.
|
||||
/// If the user does not have a relationship with the collection, nothing is returned.
|
||||
/// </summary>
|
||||
Task<CollectionDetails> GetByIdAsync(Guid id, Guid userId, bool useFlexibleCollections);
|
||||
Task<ICollection<Collection>> GetManyByManyIdsAsync(IEnumerable<Guid> collectionIds);
|
||||
|
||||
/// <summary>
|
||||
|
@ -1,4 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
#nullable enable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
@ -9,15 +11,15 @@ public class ApiKey : ITableObject<Guid>
|
||||
public Guid Id { get; set; }
|
||||
public Guid? ServiceAccountId { get; set; }
|
||||
[MaxLength(200)]
|
||||
public string Name { get; set; }
|
||||
public required string Name { get; set; }
|
||||
[MaxLength(128)]
|
||||
public string ClientSecretHash { get; set; }
|
||||
public string? ClientSecretHash { get; set; }
|
||||
[MaxLength(4000)]
|
||||
public string Scope { get; set; }
|
||||
public required string Scope { get; set; }
|
||||
[MaxLength(4000)]
|
||||
public string EncryptedPayload { get; set; }
|
||||
public required string EncryptedPayload { get; set; }
|
||||
// Key for decrypting `EncryptedPayload`. Encrypted using the organization key.
|
||||
public string Key { get; set; }
|
||||
public required string Key { get; set; }
|
||||
public DateTime? ExpireAt { get; set; }
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
|
||||
namespace Bit.Core.SecretsManager.Models.Data;
|
||||
|
||||
@ -28,6 +29,7 @@ public class ServiceAccountApiKeyDetails : ApiKeyDetails
|
||||
|
||||
}
|
||||
|
||||
[SetsRequiredMembers]
|
||||
public ServiceAccountApiKeyDetails(ApiKey apiKey, Guid organizationId) : base(apiKey)
|
||||
{
|
||||
ServiceAccountOrganizationId = organizationId;
|
||||
|
@ -17,8 +17,8 @@ public interface IUserService
|
||||
Task<User> GetUserByPrincipalAsync(ClaimsPrincipal principal);
|
||||
Task<DateTime> GetAccountRevisionDateByIdAsync(Guid userId);
|
||||
Task SaveUserAsync(User user, bool push = false);
|
||||
Task<IdentityResult> RegisterUserAsync(User user, string masterPassword, string token, Guid? orgUserId);
|
||||
Task<IdentityResult> RegisterUserAsync(User user);
|
||||
Task<IdentityResult> CreateUserAsync(User user);
|
||||
Task<IdentityResult> CreateUserAsync(User user, string masterPasswordHash);
|
||||
Task SendMasterPasswordHintAsync(string email);
|
||||
Task SendTwoFactorEmailAsync(User user);
|
||||
Task<bool> VerifyTwoFactorEmailAsync(User user, string token);
|
||||
@ -77,6 +77,9 @@ public interface IUserService
|
||||
Task<bool> VerifyOTPAsync(User user, string token);
|
||||
Task<bool> VerifySecretAsync(User user, string secret);
|
||||
|
||||
|
||||
void SetTwoFactorProvider(User user, TwoFactorProviderType type, bool setEnabled = true);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the user is a legacy user. Legacy users use their master key as their encryption key.
|
||||
/// We force these users to the web to migrate their encryption scheme.
|
||||
|
@ -59,7 +59,7 @@ public class HandlebarsMailService : IMailService
|
||||
var model = new RegisterVerifyEmail
|
||||
{
|
||||
Token = WebUtility.UrlEncode(token),
|
||||
Email = email,
|
||||
Email = WebUtility.UrlEncode(email),
|
||||
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
|
||||
SiteName = _globalSettings.SiteName
|
||||
};
|
||||
|
@ -25,7 +25,6 @@ using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using File = System.IO.File;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
|
||||
@ -289,89 +288,14 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
await _mailService.SendVerifyDeleteEmailAsync(user.Email, user.Id, token);
|
||||
}
|
||||
|
||||
public async Task<IdentityResult> RegisterUserAsync(User user, string masterPassword,
|
||||
string token, Guid? orgUserId)
|
||||
public async Task<IdentityResult> CreateUserAsync(User user)
|
||||
{
|
||||
var tokenValid = false;
|
||||
if (_globalSettings.DisableUserRegistration && !string.IsNullOrWhiteSpace(token) && orgUserId.HasValue)
|
||||
{
|
||||
// TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
|
||||
var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
|
||||
_orgUserInviteTokenDataFactory, token, orgUserId.Value, user.Email);
|
||||
|
||||
tokenValid = newTokenValid ||
|
||||
CoreHelpers.UserInviteTokenIsValid(_organizationServiceDataProtector, token,
|
||||
user.Email, orgUserId.Value, _globalSettings);
|
||||
}
|
||||
|
||||
if (_globalSettings.DisableUserRegistration && !tokenValid)
|
||||
{
|
||||
throw new BadRequestException("Open registration has been disabled by the system administrator.");
|
||||
}
|
||||
|
||||
if (orgUserId.HasValue)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value);
|
||||
if (orgUser != null)
|
||||
{
|
||||
var twoFactorPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgUser.OrganizationId,
|
||||
PolicyType.TwoFactorAuthentication);
|
||||
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
|
||||
{
|
||||
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
||||
{
|
||||
|
||||
[TwoFactorProviderType.Email] = new TwoFactorProvider
|
||||
{
|
||||
MetaData = new Dictionary<string, object> { ["Email"] = user.Email.ToLowerInvariant() },
|
||||
Enabled = true
|
||||
}
|
||||
});
|
||||
SetTwoFactorProvider(user, TwoFactorProviderType.Email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
user.ApiKey = CoreHelpers.SecureRandomString(30);
|
||||
var result = await base.CreateAsync(user, masterPassword);
|
||||
if (result == IdentityResult.Success)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(user.ReferenceData))
|
||||
{
|
||||
var referenceData = JsonConvert.DeserializeObject<Dictionary<string, object>>(user.ReferenceData);
|
||||
if (referenceData.TryGetValue("initiationPath", out var value))
|
||||
{
|
||||
var initiationPath = value.ToString();
|
||||
await SendAppropriateWelcomeEmailAsync(user, initiationPath);
|
||||
if (!string.IsNullOrEmpty(initiationPath))
|
||||
{
|
||||
await _referenceEventService.RaiseEventAsync(
|
||||
new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)
|
||||
{
|
||||
SignupInitiationPath = initiationPath
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext));
|
||||
}
|
||||
|
||||
return result;
|
||||
return await CreateAsync(user);
|
||||
}
|
||||
|
||||
public async Task<IdentityResult> RegisterUserAsync(User user)
|
||||
public async Task<IdentityResult> CreateUserAsync(User user, string masterPasswordHash)
|
||||
{
|
||||
var result = await base.CreateAsync(user);
|
||||
if (result == IdentityResult.Success)
|
||||
{
|
||||
await _mailService.SendWelcomeEmailAsync(user);
|
||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext));
|
||||
}
|
||||
|
||||
return result;
|
||||
return await CreateAsync(user, masterPasswordHash);
|
||||
}
|
||||
|
||||
public async Task SendMasterPasswordHintAsync(string email)
|
||||
|
@ -254,4 +254,9 @@ public class ReferenceEvent
|
||||
/// or when a downgrade occurred.
|
||||
/// </value>
|
||||
public string? PlanUpgradePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Used for the sign up event to determine if the user has opted in to marketing emails.
|
||||
/// </summary>
|
||||
public bool? ReceiveMarketingEmails { get; set; }
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ namespace Bit.Core.Vault.Repositories;
|
||||
|
||||
public interface ICipherRepository : IRepository<Cipher, Guid>
|
||||
{
|
||||
Task<CipherDetails> GetByIdAsync(Guid id, Guid userId, bool useFlexibleCollections);
|
||||
Task<CipherDetails> GetByIdAsync(Guid id, Guid userId);
|
||||
Task<CipherOrganizationDetails> GetOrganizationDetailsByIdAsync(Guid id);
|
||||
Task<ICollection<CipherOrganizationDetails>> GetManyOrganizationDetailsByOrganizationIdAsync(Guid organizationId);
|
||||
Task<bool> GetCanEditByIdAsync(Guid userId, Guid cipherId);
|
||||
@ -24,18 +24,18 @@ public interface ICipherRepository : IRepository<Cipher, Guid>
|
||||
Task UpdatePartialAsync(Guid id, Guid userId, Guid? folderId, bool favorite);
|
||||
Task UpdateAttachmentAsync(CipherAttachment attachment);
|
||||
Task DeleteAttachmentAsync(Guid cipherId, string attachmentId);
|
||||
Task DeleteAsync(IEnumerable<Guid> ids, Guid userId, bool useFlexibleCollections);
|
||||
Task DeleteAsync(IEnumerable<Guid> ids, Guid userId);
|
||||
Task DeleteByIdsOrganizationIdAsync(IEnumerable<Guid> ids, Guid organizationId);
|
||||
Task MoveAsync(IEnumerable<Guid> ids, Guid? folderId, Guid userId, bool useFlexibleCollections);
|
||||
Task MoveAsync(IEnumerable<Guid> ids, Guid? folderId, Guid userId);
|
||||
Task DeleteByUserIdAsync(Guid userId);
|
||||
Task DeleteByOrganizationIdAsync(Guid organizationId);
|
||||
Task UpdateCiphersAsync(Guid userId, IEnumerable<Cipher> ciphers);
|
||||
Task CreateAsync(IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders);
|
||||
Task CreateAsync(IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections,
|
||||
IEnumerable<CollectionCipher> collectionCiphers, IEnumerable<CollectionUser> collectionUsers);
|
||||
Task SoftDeleteAsync(IEnumerable<Guid> ids, Guid userId, bool useFlexibleCollections);
|
||||
Task SoftDeleteAsync(IEnumerable<Guid> ids, Guid userId);
|
||||
Task SoftDeleteByIdsOrganizationIdAsync(IEnumerable<Guid> ids, Guid organizationId);
|
||||
Task<DateTime> RestoreAsync(IEnumerable<Guid> ids, Guid userId, bool useFlexibleCollections);
|
||||
Task<DateTime> RestoreAsync(IEnumerable<Guid> ids, Guid userId);
|
||||
Task<DateTime> RestoreByIdsOrganizationIdAsync(IEnumerable<Guid> ids, Guid organizationId);
|
||||
Task DeleteDeletedAsync(DateTime deletedDateBefore);
|
||||
|
||||
|
@ -433,7 +433,7 @@ public class CipherService : ICipherService
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId, useFlexibleCollections: UseFlexibleCollections);
|
||||
deletingCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).Select(x => (Cipher)x).ToList();
|
||||
|
||||
await _cipherRepository.DeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId, UseFlexibleCollections);
|
||||
await _cipherRepository.DeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId);
|
||||
}
|
||||
|
||||
var events = deletingCiphers.Select(c =>
|
||||
@ -485,7 +485,7 @@ public class CipherService : ICipherService
|
||||
}
|
||||
}
|
||||
|
||||
await _cipherRepository.MoveAsync(cipherIds, destinationFolderId, movingUserId, UseFlexibleCollections);
|
||||
await _cipherRepository.MoveAsync(cipherIds, destinationFolderId, movingUserId);
|
||||
// push
|
||||
await _pushService.PushSyncCiphersAsync(movingUserId);
|
||||
}
|
||||
@ -585,11 +585,11 @@ public class CipherService : ICipherService
|
||||
originalCipher.SetAttachments(originalAttachments);
|
||||
}
|
||||
|
||||
var currentCollectionsForCipher = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(sharingUserId, originalCipher.Id, UseFlexibleCollections);
|
||||
var currentCollectionsForCipher = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(sharingUserId, originalCipher.Id);
|
||||
var currentCollectionIdsForCipher = currentCollectionsForCipher.Select(c => c.CollectionId).ToList();
|
||||
currentCollectionIdsForCipher.RemoveAll(id => collectionIds.Contains(id));
|
||||
|
||||
await _collectionCipherRepository.UpdateCollectionsAsync(originalCipher.Id, sharingUserId, currentCollectionIdsForCipher, UseFlexibleCollections);
|
||||
await _collectionCipherRepository.UpdateCollectionsAsync(originalCipher.Id, sharingUserId, currentCollectionIdsForCipher);
|
||||
await _cipherRepository.ReplaceAsync(originalCipher);
|
||||
}
|
||||
|
||||
@ -634,7 +634,7 @@ public class CipherService : ICipherService
|
||||
|
||||
await _cipherRepository.UpdateCiphersAsync(sharingUserId, cipherInfos.Select(c => c.cipher));
|
||||
await _collectionCipherRepository.UpdateCollectionsForCiphersAsync(cipherIds, sharingUserId,
|
||||
organizationId, collectionIds, UseFlexibleCollections);
|
||||
organizationId, collectionIds);
|
||||
|
||||
var events = cipherInfos.Select(c =>
|
||||
new Tuple<Cipher, EventType, DateTime?>(c.cipher, EventType.Cipher_Shared, null));
|
||||
@ -675,7 +675,7 @@ public class CipherService : ICipherService
|
||||
{
|
||||
throw new BadRequestException("You do not have permissions to edit this.");
|
||||
}
|
||||
await _collectionCipherRepository.UpdateCollectionsAsync(cipher.Id, savingUserId, collectionIds, UseFlexibleCollections);
|
||||
await _collectionCipherRepository.UpdateCollectionsAsync(cipher.Id, savingUserId, collectionIds);
|
||||
}
|
||||
|
||||
await _eventService.LogCipherEventAsync(cipher, Bit.Core.Enums.EventType.Cipher_UpdatedCollections);
|
||||
@ -878,7 +878,7 @@ public class CipherService : ICipherService
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId, useFlexibleCollections: UseFlexibleCollections);
|
||||
deletingCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).Select(x => (Cipher)x).ToList();
|
||||
|
||||
await _cipherRepository.SoftDeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId, UseFlexibleCollections);
|
||||
await _cipherRepository.SoftDeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId);
|
||||
}
|
||||
|
||||
var events = deletingCiphers.Select(c =>
|
||||
@ -944,7 +944,7 @@ public class CipherService : ICipherService
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(restoringUserId, useFlexibleCollections: UseFlexibleCollections);
|
||||
restoringCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).Select(c => (CipherOrganizationDetails)c).ToList();
|
||||
|
||||
revisionDate = await _cipherRepository.RestoreAsync(restoringCiphers.Select(c => c.Id), restoringUserId, UseFlexibleCollections);
|
||||
revisionDate = await _cipherRepository.RestoreAsync(restoringCiphers.Select(c => c.Id), restoringUserId);
|
||||
}
|
||||
|
||||
var events = restoringCiphers.Select(c =>
|
||||
|
@ -1,5 +1,4 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -73,10 +72,8 @@ public class CollectController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
var useFlexibleCollections = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections);
|
||||
cipher = await _cipherRepository.GetByIdAsync(eventModel.CipherId.Value,
|
||||
_currentContext.UserId.Value,
|
||||
useFlexibleCollections);
|
||||
_currentContext.UserId.Value);
|
||||
}
|
||||
if (cipher == null)
|
||||
{
|
||||
|
@ -8,6 +8,7 @@ using Bit.Core.Auth.UserFeatures.Registration;
|
||||
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
|
||||
using Bit.Core.Auth.Utilities;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data;
|
||||
@ -21,6 +22,7 @@ using Bit.Core.Utilities;
|
||||
using Bit.Identity.Models.Request.Accounts;
|
||||
using Bit.Identity.Models.Response.Accounts;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Identity.Controllers;
|
||||
@ -32,35 +34,37 @@ public class AccountsController : Controller
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ILogger<AccountsController> _logger;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IRegisterUserCommand _registerUserCommand;
|
||||
private readonly ICaptchaValidationService _captchaValidationService;
|
||||
private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
|
||||
private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand;
|
||||
private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public AccountsController(
|
||||
ICurrentContext currentContext,
|
||||
ILogger<AccountsController> logger,
|
||||
IUserRepository userRepository,
|
||||
IUserService userService,
|
||||
IRegisterUserCommand registerUserCommand,
|
||||
ICaptchaValidationService captchaValidationService,
|
||||
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector,
|
||||
IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand,
|
||||
ISendVerificationEmailForRegistrationCommand sendVerificationEmailForRegistrationCommand,
|
||||
IReferenceEventService referenceEventService
|
||||
IReferenceEventService referenceEventService,
|
||||
IFeatureService featureService
|
||||
)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_logger = logger;
|
||||
_userRepository = userRepository;
|
||||
_userService = userService;
|
||||
_registerUserCommand = registerUserCommand;
|
||||
_captchaValidationService = captchaValidationService;
|
||||
_assertionOptionsDataProtector = assertionOptionsDataProtector;
|
||||
_getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand;
|
||||
_sendVerificationEmailForRegistrationCommand = sendVerificationEmailForRegistrationCommand;
|
||||
_referenceEventService = referenceEventService;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
@ -68,21 +72,10 @@ public class AccountsController : Controller
|
||||
public async Task<RegisterResponseModel> PostRegister([FromBody] RegisterRequestModel model)
|
||||
{
|
||||
var user = model.ToUser();
|
||||
var result = await _userService.RegisterUserAsync(user, model.MasterPasswordHash,
|
||||
var identityResult = await _registerUserCommand.RegisterUserWithOptionalOrgInvite(user, model.MasterPasswordHash,
|
||||
model.Token, model.OrganizationUserId);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
var captchaBypassToken = _captchaValidationService.GenerateCaptchaBypassToken(user);
|
||||
return new RegisterResponseModel(captchaBypassToken);
|
||||
}
|
||||
|
||||
foreach (var error in result.Errors.Where(e => e.Code != "DuplicateUserName"))
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
}
|
||||
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException(ModelState);
|
||||
// delaysEnabled false is only for the new registration with email verification process
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled: true);
|
||||
}
|
||||
|
||||
[RequireFeature(FeatureFlagKeys.EmailVerification)]
|
||||
@ -109,6 +102,50 @@ public class AccountsController : Controller
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[RequireFeature(FeatureFlagKeys.EmailVerification)]
|
||||
[HttpPost("register/finish")]
|
||||
public async Task<RegisterResponseModel> PostRegisterFinish([FromBody] RegisterFinishRequestModel model)
|
||||
{
|
||||
var user = model.ToUser();
|
||||
|
||||
// Users will either have an org invite token or an email verification token - not both.
|
||||
|
||||
IdentityResult identityResult = null;
|
||||
var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays);
|
||||
|
||||
if (!string.IsNullOrEmpty(model.OrgInviteToken) && model.OrganizationUserId.HasValue)
|
||||
{
|
||||
identityResult = await _registerUserCommand.RegisterUserWithOptionalOrgInvite(user, model.MasterPasswordHash,
|
||||
model.OrgInviteToken, model.OrganizationUserId);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
}
|
||||
|
||||
identityResult = await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash, model.EmailVerificationToken);
|
||||
|
||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
||||
}
|
||||
|
||||
private async Task<RegisterResponseModel> ProcessRegistrationResult(IdentityResult result, User user, bool delaysEnabled)
|
||||
{
|
||||
if (result.Succeeded)
|
||||
{
|
||||
var captchaBypassToken = _captchaValidationService.GenerateCaptchaBypassToken(user);
|
||||
return new RegisterResponseModel(captchaBypassToken);
|
||||
}
|
||||
|
||||
foreach (var error in result.Errors.Where(e => e.Code != "DuplicateUserName"))
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
}
|
||||
|
||||
if (delaysEnabled)
|
||||
{
|
||||
await Task.Delay(Random.Shared.Next(100, 130));
|
||||
}
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
|
||||
// Moved from API, If you modify this endpoint, please update API as well. Self hosted installs still use the API endpoints.
|
||||
[HttpPost("prelogin")]
|
||||
public async Task<PreloginResponseModel> PostPrelogin([FromBody] PreloginRequestModel model)
|
||||
|
@ -142,7 +142,7 @@ public class GroupRepository : Repository<Group, Guid>, IGroupRepository
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.ExecuteAsync(
|
||||
$"[{Schema}].[Group_CreateWithCollections_V2]",
|
||||
$"[{Schema}].[Group_CreateWithCollections]",
|
||||
objWithCollections,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
@ -156,7 +156,7 @@ public class GroupRepository : Repository<Group, Guid>, IGroupRepository
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.ExecuteAsync(
|
||||
$"[{Schema}].[Group_UpdateWithCollections_V2]",
|
||||
$"[{Schema}].[Group_UpdateWithCollections]",
|
||||
objWithCollections,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
@ -329,7 +329,7 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.ExecuteAsync(
|
||||
$"[{Schema}].[OrganizationUser_CreateWithCollections_V2]",
|
||||
$"[{Schema}].[OrganizationUser_CreateWithCollections]",
|
||||
objWithCollections,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
@ -346,7 +346,7 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.ExecuteAsync(
|
||||
$"[{Schema}].[OrganizationUser_UpdateWithCollections_V2]",
|
||||
$"[{Schema}].[OrganizationUser_UpdateWithCollections]",
|
||||
objWithCollections,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
@ -17,16 +17,12 @@ public class CollectionCipherRepository : BaseRepository, ICollectionCipherRepos
|
||||
: base(connectionString, readOnlyConnectionString)
|
||||
{ }
|
||||
|
||||
public async Task<ICollection<CollectionCipher>> GetManyByUserIdAsync(Guid userId, bool useFlexibleCollections)
|
||||
public async Task<ICollection<CollectionCipher>> GetManyByUserIdAsync(Guid userId)
|
||||
{
|
||||
var sprocName = useFlexibleCollections
|
||||
? "[dbo].[CollectionCipher_ReadByUserId_V2]"
|
||||
: "[dbo].[CollectionCipher_ReadByUserId]";
|
||||
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.QueryAsync<CollectionCipher>(
|
||||
sprocName,
|
||||
"[dbo].[CollectionCipher_ReadByUserId]",
|
||||
new { UserId = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
@ -47,16 +43,12 @@ public class CollectionCipherRepository : BaseRepository, ICollectionCipherRepos
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ICollection<CollectionCipher>> GetManyByUserIdCipherIdAsync(Guid userId, Guid cipherId, bool useFlexibleCollections)
|
||||
public async Task<ICollection<CollectionCipher>> GetManyByUserIdCipherIdAsync(Guid userId, Guid cipherId)
|
||||
{
|
||||
var sprocName = useFlexibleCollections
|
||||
? "[dbo].[CollectionCipher_ReadByUserIdCipherId_V2]"
|
||||
: "[dbo].[CollectionCipher_ReadByUserIdCipherId]";
|
||||
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.QueryAsync<CollectionCipher>(
|
||||
sprocName,
|
||||
"[dbo].[CollectionCipher_ReadByUserIdCipherId]",
|
||||
new { UserId = userId, CipherId = cipherId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
@ -64,16 +56,12 @@ public class CollectionCipherRepository : BaseRepository, ICollectionCipherRepos
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateCollectionsAsync(Guid cipherId, Guid userId, IEnumerable<Guid> collectionIds, bool useFlexibleCollections)
|
||||
public async Task UpdateCollectionsAsync(Guid cipherId, Guid userId, IEnumerable<Guid> collectionIds)
|
||||
{
|
||||
var sprocName = useFlexibleCollections
|
||||
? "[dbo].[CollectionCipher_UpdateCollections_V2]"
|
||||
: "[dbo].[CollectionCipher_UpdateCollections]";
|
||||
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.ExecuteAsync(
|
||||
sprocName,
|
||||
"[dbo].[CollectionCipher_UpdateCollections]",
|
||||
new { CipherId = cipherId, UserId = userId, CollectionIds = collectionIds.ToGuidIdArrayTVP() },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
@ -91,16 +79,12 @@ public class CollectionCipherRepository : BaseRepository, ICollectionCipherRepos
|
||||
}
|
||||
|
||||
public async Task UpdateCollectionsForCiphersAsync(IEnumerable<Guid> cipherIds, Guid userId,
|
||||
Guid organizationId, IEnumerable<Guid> collectionIds, bool useFlexibleCollections)
|
||||
Guid organizationId, IEnumerable<Guid> collectionIds)
|
||||
{
|
||||
var sprocName = useFlexibleCollections
|
||||
? "[dbo].[CollectionCipher_UpdateCollectionsForCiphers_V2]"
|
||||
: "[dbo].[CollectionCipher_UpdateCollectionsForCiphers]";
|
||||
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.ExecuteAsync(
|
||||
sprocName,
|
||||
"[dbo].[CollectionCipher_UpdateCollectionsForCiphers]",
|
||||
new
|
||||
{
|
||||
CipherIds = cipherIds.ToGuidIdArrayTVP(),
|
||||
|
@ -50,29 +50,6 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Tuple<CollectionDetails, CollectionAccessDetails>> GetByIdWithAccessAsync(
|
||||
Guid id, Guid userId, bool useFlexibleCollections)
|
||||
{
|
||||
var sprocName = useFlexibleCollections
|
||||
? $"[{Schema}].[Collection_ReadWithGroupsAndUsersByIdUserId_V2]"
|
||||
: $"[{Schema}].[Collection_ReadWithGroupsAndUsersByIdUserId]";
|
||||
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.QueryMultipleAsync(
|
||||
sprocName,
|
||||
new { Id = id, UserId = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
var collection = await results.ReadFirstOrDefaultAsync<CollectionDetails>();
|
||||
var groups = (await results.ReadAsync<CollectionAccessSelection>()).ToList();
|
||||
var users = (await results.ReadAsync<CollectionAccessSelection>()).ToList();
|
||||
var access = new CollectionAccessDetails { Groups = groups, Users = users };
|
||||
|
||||
return new Tuple<CollectionDetails, CollectionAccessDetails>(collection, access);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ICollection<Collection>> GetManyByManyIdsAsync(IEnumerable<Guid> collectionIds)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
@ -143,71 +120,6 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ICollection<Tuple<CollectionDetails, CollectionAccessDetails>>> GetManyByUserIdWithAccessAsync(Guid userId, Guid organizationId, bool useFlexibleCollections)
|
||||
{
|
||||
var sprocName = useFlexibleCollections
|
||||
? $"[{Schema}].[Collection_ReadWithGroupsAndUsersByUserId_V2]"
|
||||
: $"[{Schema}].[Collection_ReadWithGroupsAndUsersByUserId]";
|
||||
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.QueryMultipleAsync(
|
||||
sprocName,
|
||||
new { UserId = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
var collections = (await results.ReadAsync<CollectionDetails>()).Where(c => c.OrganizationId == organizationId);
|
||||
var groups = (await results.ReadAsync<CollectionGroup>())
|
||||
.GroupBy(g => g.CollectionId);
|
||||
var users = (await results.ReadAsync<CollectionUser>())
|
||||
.GroupBy(u => u.CollectionId);
|
||||
|
||||
return collections.Select(collection =>
|
||||
new Tuple<CollectionDetails, CollectionAccessDetails>(
|
||||
collection,
|
||||
new CollectionAccessDetails
|
||||
{
|
||||
Groups = groups
|
||||
.FirstOrDefault(g => g.Key == collection.Id)?
|
||||
.Select(g => new CollectionAccessSelection
|
||||
{
|
||||
Id = g.GroupId,
|
||||
HidePasswords = g.HidePasswords,
|
||||
ReadOnly = g.ReadOnly,
|
||||
Manage = g.Manage
|
||||
}).ToList() ?? new List<CollectionAccessSelection>(),
|
||||
Users = users
|
||||
.FirstOrDefault(u => u.Key == collection.Id)?
|
||||
.Select(c => new CollectionAccessSelection
|
||||
{
|
||||
Id = c.OrganizationUserId,
|
||||
HidePasswords = c.HidePasswords,
|
||||
ReadOnly = c.ReadOnly,
|
||||
Manage = c.Manage
|
||||
}).ToList() ?? new List<CollectionAccessSelection>()
|
||||
}
|
||||
)
|
||||
).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CollectionDetails> GetByIdAsync(Guid id, Guid userId, bool useFlexibleCollections)
|
||||
{
|
||||
var sprocName = useFlexibleCollections
|
||||
? $"[{Schema}].[Collection_ReadByIdUserId_V2]"
|
||||
: $"[{Schema}].[Collection_ReadByIdUserId]";
|
||||
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.QueryAsync<CollectionDetails>(
|
||||
sprocName,
|
||||
new { Id = id, UserId = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ICollection<CollectionDetails>> GetManyByUserIdAsync(Guid userId, bool useFlexibleCollections)
|
||||
{
|
||||
var sprocName = useFlexibleCollections
|
||||
@ -305,7 +217,7 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.ExecuteAsync(
|
||||
$"[{Schema}].[Collection_CreateWithGroupsAndUsers_V2]",
|
||||
$"[{Schema}].[Collection_CreateWithGroupsAndUsers]",
|
||||
objWithGroupsAndUsers,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
@ -321,7 +233,7 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.ExecuteAsync(
|
||||
$"[{Schema}].[Collection_UpdateWithGroupsAndUsers_V2]",
|
||||
$"[{Schema}].[Collection_UpdateWithGroupsAndUsers]",
|
||||
objWithGroupsAndUsers,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
@ -378,7 +290,7 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.ExecuteAsync(
|
||||
$"[{Schema}].[CollectionUser_UpdateUsers_V2]",
|
||||
$"[{Schema}].[CollectionUser_UpdateUsers]",
|
||||
new { CollectionId = id, Users = users.ToArrayTVP() },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user