1
0
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:
Nicolai Søborg 2024-07-06 16:11:23 +02:00 committed by GitHub
commit c491d4478b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
207 changed files with 51421 additions and 3532 deletions

1
.gitignore vendored
View File

@ -215,6 +215,7 @@ bitwarden_license/src/Sso/wwwroot/css
**/CoverageOutput/
.idea/*
**/**.swp
.mono
src/Admin/Admin.zip
src/Api/Api.zip

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -101,6 +101,7 @@ public class OrganizationUserUpdateRequestModel
public class OrganizationUserResetPasswordEnrollmentRequestModel
{
public string ResetPasswordKey { get; set; }
public string MasterPasswordHash { get; set; }
}
public class OrganizationUserBulkRequestModel

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
namespace Bit.Core.AdminConsole.Entities;
#nullable enable
public class GroupUser
{
public Guid GroupId { get; set; }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,8 @@
using Bit.Core.Entities;
using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.Billing.Entities;
public class ProviderPlan : ITableObject<Guid>

View File

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

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Billing.Models;
public record SubscriptionSuspensionDTO(
DateTime SuspensionDate,
DateTime UnpaidPeriodEndDate,
int GracePeriod);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
namespace Bit.Core.Entities;
#nullable enable
public class CollectionCipher
{
public Guid CollectionId { get; set; }

View File

@ -1,5 +1,7 @@
namespace Bit.Core.Entities;
#nullable enable
public class CollectionGroup
{
public Guid CollectionId { get; set; }

View File

@ -1,5 +1,7 @@
namespace Bit.Core.Entities;
#nullable enable
public class CollectionUser
{
public Guid CollectionId { get; set; }

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
namespace Bit.Core.Entities;
#nullable enable
public interface IRevisable
{
DateTime CreationDate { get; }

View File

@ -1,5 +1,7 @@
namespace Bit.Core.Entities;
#nullable enable
public interface IStorable
{
long? Storage { get; set; }

View File

@ -1,4 +1,6 @@
namespace Bit.Core.Entities;
#nullable enable
public interface IStorableSubscriber : IStorable, ISubscriber
{ }

View File

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

View File

@ -1,5 +1,7 @@
namespace Bit.Core.Entities;
#nullable enable
public interface ITableObject<T> where T : IEquatable<T>
{
T Id { get; set; }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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